diff --git a/.coveragerc b/.coveragerc index 2c768060108..50fcf151821 100644 --- a/.coveragerc +++ b/.coveragerc @@ -23,6 +23,8 @@ omit = homeassistant/components/adguard/sensor.py homeassistant/components/adguard/switch.py homeassistant/components/ads/* + homeassistant/components/aemet/abstract_aemet_sensor.py + homeassistant/components/aemet/weather_update_coordinator.py homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/__init__.py homeassistant/components/agent_dvr/alarm_control_panel.py @@ -67,11 +69,14 @@ omit = homeassistant/components/arwn/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* + homeassistant/components/asuswrt/__init__.py + homeassistant/components/asuswrt/router.py homeassistant/components/aten_pe/* homeassistant/components/atome/* homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py homeassistant/components/aurora/const.py + homeassistant/components/aurora/sensor.py homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py @@ -140,6 +145,7 @@ omit = homeassistant/components/clickatell/notify.py homeassistant/components/clicksend/notify.py homeassistant/components/clicksend_tts/notify.py + homeassistant/components/climacell/weather.py homeassistant/components/cmus/media_player.py homeassistant/components/co2signal/* homeassistant/components/coinbase/* @@ -156,7 +162,6 @@ omit = homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/cpuspeed/sensor.py - homeassistant/components/crimereports/sensor.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py homeassistant/components/daikin/* @@ -172,7 +177,6 @@ omit = homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py homeassistant/components/deutsche_bahn/sensor.py - homeassistant/components/devolo_home_control/__init__.py homeassistant/components/devolo_home_control/binary_sensor.py homeassistant/components/devolo_home_control/climate.py homeassistant/components/devolo_home_control/const.py @@ -268,6 +272,8 @@ omit = homeassistant/components/evohome/* homeassistant/components/ezviz/* homeassistant/components/familyhub/camera.py + homeassistant/components/faa_delays/__init__.py + homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/* @@ -358,7 +364,9 @@ omit = homeassistant/components/guardian/sensor.py homeassistant/components/guardian/switch.py homeassistant/components/guardian/util.py - homeassistant/components/habitica/* + homeassistant/components/habitica/__init__.py + homeassistant/components/habitica/const.py + homeassistant/components/habitica/sensor.py homeassistant/components/hangouts/* homeassistant/components/hangouts/__init__.py homeassistant/components/hangouts/const.py @@ -463,7 +471,11 @@ omit = homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* + homeassistant/components/keenetic_ndms2/__init__.py + homeassistant/components/keenetic_ndms2/binary_sensor.py + homeassistant/components/keenetic_ndms2/const.py homeassistant/components/keenetic_ndms2/device_tracker.py + homeassistant/components/keenetic_ndms2/router.py homeassistant/components/kef/* homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* @@ -519,6 +531,10 @@ omit = homeassistant/components/lutron_caseta/switch.py homeassistant/components/lw12wifi/light.py homeassistant/components/lyft/sensor.py + homeassistant/components/lyric/__init__.py + homeassistant/components/lyric/api.py + homeassistant/components/lyric/climate.py + homeassistant/components/lyric/sensor.py homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* @@ -560,6 +576,7 @@ omit = homeassistant/components/mochad/* homeassistant/components/modbus/climate.py homeassistant/components/modbus/cover.py + homeassistant/components/modbus/modbus.py homeassistant/components/modbus/switch.py homeassistant/components/modbus/sensor.py homeassistant/components/modem_callerid/sensor.py @@ -571,11 +588,27 @@ omit = homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py + homeassistant/components/mullvad/__init__.py + homeassistant/components/mullvad/binary_sensor.py + homeassistant/components/nest/const.py homeassistant/components/mvglive/sensor.py homeassistant/components/mychevy/* homeassistant/components/mycroft/* homeassistant/components/mycroft/notify.py - homeassistant/components/mysensors/* + homeassistant/components/mysensors/__init__.py + homeassistant/components/mysensors/binary_sensor.py + homeassistant/components/mysensors/climate.py + homeassistant/components/mysensors/const.py + homeassistant/components/mysensors/cover.py + homeassistant/components/mysensors/device.py + homeassistant/components/mysensors/device_tracker.py + homeassistant/components/mysensors/gateway.py + homeassistant/components/mysensors/handler.py + homeassistant/components/mysensors/helpers.py + homeassistant/components/mysensors/light.py + homeassistant/components/mysensors/notify.py + homeassistant/components/mysensors/sensor.py + homeassistant/components/mysensors/switch.py homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py @@ -621,7 +654,8 @@ omit = homeassistant/components/norway_air/air_quality.py homeassistant/components/notify_events/notify.py homeassistant/components/nsw_fuel_station/sensor.py - homeassistant/components/nuimo_controller/* + homeassistant/components/nuki/__init__.py + homeassistant/components/nuki/const.py homeassistant/components/nuki/lock.py homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py @@ -687,6 +721,7 @@ omit = homeassistant/components/pandora/media_player.py homeassistant/components/pcal9535a/* homeassistant/components/pencom/switch.py + homeassistant/components/philips_js/__init__.py homeassistant/components/philips_js/media_player.py homeassistant/components/pi_hole/sensor.py homeassistant/components/pi4ioe5v9xxxx/binary_sensor.py @@ -699,7 +734,11 @@ omit = homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py - homeassistant/components/plaato/* + homeassistant/components/plaato/__init__.py + homeassistant/components/plaato/binary_sensor.py + homeassistant/components/plaato/const.py + homeassistant/components/plaato/entity.py + homeassistant/components/plaato/sensor.py homeassistant/components/plex/media_player.py homeassistant/components/plum_lightpad/light.py homeassistant/components/pocketcasts/sensor.py @@ -754,6 +793,8 @@ omit = homeassistant/components/rest/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py + homeassistant/components/rituals_perfume_genie/switch.py + homeassistant/components/rituals_perfume_genie/__init__.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/binary_sensor.py homeassistant/components/roomba/braava.py @@ -876,7 +917,6 @@ omit = homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthru/sensor.py - homeassistant/components/synology/camera.py homeassistant/components/synology_chat/notify.py homeassistant/components/synology_dsm/__init__.py homeassistant/components/synology_dsm/binary_sensor.py @@ -944,7 +984,10 @@ omit = homeassistant/components/toon/sensor.py homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py - homeassistant/components/totalconnect/* + homeassistant/components/totalconnect/__init__.py + homeassistant/components/totalconnect/alarm_control_panel.py + homeassistant/components/totalconnect/binary_sensor.py + homeassistant/components/totalconnect/const.py homeassistant/components/touchline/climate.py homeassistant/components/tplink/common.py homeassistant/components/tplink/switch.py @@ -963,7 +1006,14 @@ omit = homeassistant/components/transmission/const.py homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py - homeassistant/components/tuya/* + homeassistant/components/tuya/__init__.py + homeassistant/components/tuya/climate.py + homeassistant/components/tuya/const.py + homeassistant/components/tuya/cover.py + homeassistant/components/tuya/fan.py + homeassistant/components/tuya/light.py + homeassistant/components/tuya/scene.py + homeassistant/components/tuya/switch.py homeassistant/components/twentemilieu/const.py homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py @@ -1001,6 +1051,7 @@ omit = homeassistant/components/vesync/common.py homeassistant/components/vesync/const.py homeassistant/components/vesync/fan.py + homeassistant/components/vesync/light.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/* @@ -1043,7 +1094,6 @@ omit = homeassistant/components/xbox/sensor.py homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py - homeassistant/components/xfinity/device_tracker.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -1056,6 +1106,7 @@ omit = homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/device.py homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py homeassistant/components/xiaomi_miio/gateway.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e01a97425e1..efcc0380748 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,13 +2,14 @@ "name": "Home Assistant Dev", "context": "..", "dockerFile": "../Dockerfile.dev", - "postCreateCommand": "mkdir -p config && pip3 install -e .", + "postCreateCommand": "script/setup", + "postStartCommand": "script/bootstrap", + "containerEnv": { "DEVCONTAINER": "1" }, "appPort": 8123, "runArgs": ["-e", "GIT_EDITOR=code --wait"], "extensions": [ "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", - "ms-azure-devops.azure-pipelines", "redhat.vscode-yaml", "esbenp.prettier-vscode" ], @@ -19,12 +20,11 @@ "python.linting.enabled": true, "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], - "python.testing.pytestEnabled": true, "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, - "terminal.integrated.shell.linux": "/bin/bash", + "terminal.integrated.shell.linux": "/usr/bin/zsh", "yaml.customTags": [ "!input scalar", "!secret scalar", diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md deleted file mode 100644 index bdadc5678ff..00000000000 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: Report a bug with Home Assistant Core -about: Report an issue with Home Assistant Core ---- - -## The problem - - - -## Environment - - -- Home Assistant Core release with the issue: -- Last working Home Assistant Core release (if known): -- Operating environment (OS/Container/Supervised/Core): -- Integration causing this issue: -- Link to integration documentation on our website: - -## Problem-relevant `configuration.yaml` - - -```yaml - -``` - -## Traceback/Error logs - - -```txt - -``` - -## Additional information - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..9a46dd82215 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,102 @@ +name: Report an issue with Home Assistant Core +about: Report an issue with Home Assistant Core. +title: "" +issue_body: true +body: + - type: markdown + attributes: + value: | + This issue form is for reporting bugs only! + + If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr]. + + [fr]: https://community.home-assistant.io/c/feature-requests + - type: textarea + validations: + required: true + attributes: + label: The problem + description: >- + Describe the issue you are experiencing here to communicate to the + maintainers. Tell us what you were trying to do and what happened. + + Provide a clear and concise description of what the problem is. + - type: markdown + attributes: + value: | + ## Environment + - type: input + validations: + required: true + attributes: + label: What is version of Home Assistant Core has the issue? + placeholder: core- + description: > + Can be found in the Configuration panel -> Info. + - type: input + attributes: + label: What was the last working version of Home Assistant Core? + placeholder: core- + description: > + If known, otherwise leave blank. + - type: dropdown + validations: + required: true + attributes: + label: What type of installation are you running? + description: > + If you don't know, you can find it in: Configuration panel -> Info. + options: + - Home Assistant OS + - Home Assistant Container + - Home Assistant Supervised + - Home Assistant Core + - type: input + attributes: + label: Integration causing the issue + description: > + The name of the integration, for example, Automation or Philips Hue. + - type: input + attributes: + label: Link to integration documentation on our website + placeholder: "https://www.home-assistant.io/integrations/..." + description: | + Providing a link [to the documentation][docs] help us categorizing the + issue, while providing a useful reference at the same time. + + [docs]: https://www.home-assistant.io/integrations + + - type: markdown + attributes: + value: | + # Details + - type: textarea + attributes: + label: Example YAML snippet + description: | + If this issue has an example piece of YAML that can help reproducing this problem, please provide. + This can be an piece of YAML from, e.g., an automation, script, scene or configuration. + value: | + ```yaml + # Put your YAML below this line + + ``` + - type: textarea + attributes: + label: Anything in the logs that might be useful for us? + description: For example, error message, or stack traces. + value: | + ```txt + # Put your logs below this line + + ``` + - type: markdown + attributes: + value: | + ## Additional information + - type: markdown + attributes: + value: > + If you have any additional information for us, use the field below. + Please note, you can attach screenshots or screen recordings here, by + dragging and dropping files in the field below. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6356803cbef..28410123914 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -52,7 +52,7 @@ jobs: pip install -r requirements.txt -r requirements_test.txt - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -79,7 +79,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -95,7 +95,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -124,7 +124,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -140,7 +140,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -169,7 +169,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -185,7 +185,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -236,7 +236,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -252,7 +252,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -284,7 +284,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -300,7 +300,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -332,7 +332,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -348,7 +348,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -377,7 +377,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -393,7 +393,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -425,7 +425,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -441,7 +441,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -481,7 +481,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -497,7 +497,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -528,7 +528,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -560,7 +560,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -591,7 +591,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -630,7 +630,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -664,7 +664,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -700,7 +700,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- @@ -760,7 +760,7 @@ jobs: uses: actions/checkout@v2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2 + uses: actions/cache@v2.1.4 with: path: venv key: >- diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6daeccc4aca..a280d0ee89a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # - No PRs marked as no-stale # - No issues marked as no-stale or help-wanted - name: 90 days stale issues & PRs policy - uses: actions/stale@v3.0.15 + uses: actions/stale@v3.0.17 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No PRs marked as no-stale or new-integrations # - No issues (-1) - name: 30 days stale PRs policy - uses: actions/stale@v3.0.15 + uses: actions/stale@v3.0.17 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 @@ -78,7 +78,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v3.0.15 + uses: actions/stale@v3.0.17 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "needs-more-information" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8944a69d9ed..6b650ebac79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,8 +59,8 @@ repos: rev: v1.24.2 hooks: - id: yamllint - - repo: https://github.com/prettier/prettier - rev: 2.0.4 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.2.1 hooks: - id: prettier stages: [manual] @@ -90,4 +90,4 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(manifest|strings)\.json|\.coveragerc)$ + files: ^(homeassistant/.+/(manifest|strings)\.json|\.coveragerc|homeassistant/.+/services\.yaml)$ diff --git a/CODEOWNERS b/CODEOWNERS index ae62631dcea..b0a31203009 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -24,6 +24,7 @@ homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 +homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks @@ -82,6 +83,7 @@ homeassistant/components/circuit/* @braam homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl +homeassistant/components/climacell/* @raman325 homeassistant/components/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus @ctalkington homeassistant/components/color_extractor/* @GenericStudent @@ -144,6 +146,7 @@ homeassistant/components/esphome/* @OttoWinter homeassistant/components/essent/* @TheLastProject homeassistant/components/evohome/* @zxdavb homeassistant/components/ezviz/* @baqs +homeassistant/components/faa_delays/* @ntilley905 homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes @@ -158,7 +161,7 @@ homeassistant/components/flunearyou/* @bachya homeassistant/components/forked_daapd/* @uvjustin homeassistant/components/fortios/* @kimfrellsen homeassistant/components/foscam/* @skgsergio -homeassistant/components/freebox/* @snoof85 @Quentame +homeassistant/components/freebox/* @hacf-fr @Quentame homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garmin_connect/* @cyberjunky @@ -181,6 +184,7 @@ homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/guardian/* @bachya +homeassistant/components/habitica/* @ASMfreaK @leikoilja homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/hdmi_cec/* @newAM @@ -241,6 +245,7 @@ homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel homeassistant/components/kef/* @basnijholt homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/kmtronic/* @dgomes homeassistant/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi homeassistant/components/konnected/* @heythisisnate @kit-klein @@ -250,6 +255,8 @@ homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus homeassistant/components/life360/* @pnbruckner homeassistant/components/linux_battery/* @fabaff +homeassistant/components/litejet/* @joncar +homeassistant/components/litterrobot/* @natekspencer homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd @@ -260,8 +267,10 @@ homeassistant/components/luftdaten/* @fabaff homeassistant/components/lupusec/* @majuss homeassistant/components/lutron/* @JonGilmore homeassistant/components/lutron_caseta/* @swails @bdraco +homeassistant/components/lyric/* @timmo001 homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf +homeassistant/components/mazda/* @bdr99 homeassistant/components/mcp23017/* @jardiamj homeassistant/components/media_source/* @hunterjm homeassistant/components/mediaroom/* @dgomes @@ -283,10 +292,12 @@ homeassistant/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff homeassistant/components/motion_blinds/* @starkillerOG homeassistant/components/mpd/* @fabaff -homeassistant/components/mqtt/* @home-assistant/core @emontnemery +homeassistant/components/mqtt/* @emontnemery homeassistant/components/msteams/* @peroyvind +homeassistant/components/mullvad/* @meichthys +homeassistant/components/my/* @home-assistant/core homeassistant/components/myq/* @bdraco -homeassistant/components/mysensors/* @MartinHjelmare +homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM @@ -310,7 +321,7 @@ homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuheat/* @bdraco -homeassistant/components/nuki/* @pschmitt @pvizeli +homeassistant/components/nuki/* @pschmitt @pvizeli @pree homeassistant/components/numato/* @clssn homeassistant/components/number/* @home-assistant/core @Shulyaka homeassistant/components/nut/* @bdraco @@ -374,9 +385,11 @@ homeassistant/components/random/* @fabaff homeassistant/components/recollect_waste/* @bachya homeassistant/components/rejseplanen/* @DarkFox homeassistant/components/repetier/* @MTrab +homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 homeassistant/components/ring/* @balloob homeassistant/components/risco/* @OnFreund +homeassistant/components/rituals_perfume_genie/* @milanmeu homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roku/* @ctalkington homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn @@ -416,6 +429,7 @@ homeassistant/components/smappee/* @bsmappee homeassistant/components/smart_meter_texas/* @grahamwetzler homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre +homeassistant/components/smarttub/* @mdz homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/sms/* @ocalvo homeassistant/components/smtp/* @fabaff @@ -442,6 +456,7 @@ homeassistant/components/stiebel_eltron/* @fucm homeassistant/components/stookalert/* @fwestenberg homeassistant/components/stream/* @hunterjm @uvjustin homeassistant/components/stt/* @pvizeli +homeassistant/components/subaru/* @G-Two homeassistant/components/suez_water/* @ooii homeassistant/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek @@ -455,7 +470,7 @@ homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/tado/* @michaelarnauts @bdraco +homeassistant/components/tado/* @michaelarnauts @bdraco @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages @@ -477,7 +492,6 @@ homeassistant/components/toon/* @frenck homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus -homeassistant/components/tradfri/* @ggravlingen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins @@ -485,6 +499,7 @@ homeassistant/components/tts/* @pvizeli homeassistant/components/tuya/* @ollo69 homeassistant/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb +homeassistant/components/ubus/* @noltari homeassistant/components/unifi/* @Kane610 homeassistant/components/unifiled/* @florisvdk homeassistant/components/upb/* @gwww @@ -506,7 +521,7 @@ homeassistant/components/vicare/* @oischinger homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 -homeassistant/components/vlc_telnet/* @rodripf +homeassistant/components/vlc_telnet/* @rodripf @dmcc homeassistant/components/volkszaehler/* @fabaff homeassistant/components/volumio/* @OnFreund homeassistant/components/waqi/* @andrey-git @@ -514,6 +529,7 @@ homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid homeassistant/components/websocket_api/* @home-assistant/core +homeassistant/components/wemo/* @esev homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj homeassistant/components/withings/* @vangorra @@ -523,7 +539,6 @@ homeassistant/components/workday/* @fabaff homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox/* @hunterjm homeassistant/components/xbox_live/* @MartinHjelmare -homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG homeassistant/components/xiaomi_tv/* @simse diff --git a/Dockerfile.dev b/Dockerfile.dev index d72ebcaed01..09f8f155930 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,11 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8 +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + RUN \ - apt-get update && apt-get install -y --no-install-recommends \ + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ libudev-dev \ libavformat-dev \ libavcodec-dev \ @@ -10,6 +14,7 @@ RUN \ libswscale-dev \ libswresample-dev \ libavfilter-dev \ + libpcap-dev \ git \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 418fdf5b26c..74aa05e58f3 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -14,7 +14,7 @@ schedules: always: true variables: - name: versionBuilder - value: '2020.11.0' + value: '2021.02.0' - group: docker - group: github - group: twine @@ -114,10 +114,12 @@ stages: pool: vmImage: 'ubuntu-latest' strategy: - maxParallel: 15 + maxParallel: 17 matrix: qemux86-64: buildMachine: 'qemux86-64' + generic-x86-64: + buildMachine: 'generic-x86-64' intel-nuc: buildMachine: 'intel-nuc' qemux86: diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e36eb6800fa..7d6f94dda85 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -1,4 +1,6 @@ """Provide an authentication layer for Home Assistant.""" +from __future__ import annotations + import asyncio from collections import OrderedDict from datetime import timedelta @@ -24,11 +26,19 @@ _ProviderKey = Tuple[str, Optional[str]] _ProviderDict = Dict[_ProviderKey, AuthProvider] +class InvalidAuthError(Exception): + """Raised when a authentication error occurs.""" + + +class InvalidProvider(Exception): + """Authentication provider not found.""" + + async def auth_manager_from_config( hass: HomeAssistant, provider_configs: List[Dict[str, Any]], module_configs: List[Dict[str, Any]], -) -> "AuthManager": +) -> AuthManager: """Initialize an auth manager from config. CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or @@ -68,7 +78,7 @@ async def auth_manager_from_config( class AuthManagerFlowManager(data_entry_flow.FlowManager): """Manage authentication flows.""" - def __init__(self, hass: HomeAssistant, auth_manager: "AuthManager"): + def __init__(self, hass: HomeAssistant, auth_manager: AuthManager): """Init auth manager flows.""" super().__init__(hass) self.auth_manager = auth_manager @@ -96,7 +106,7 @@ class AuthManagerFlowManager(data_entry_flow.FlowManager): return result # we got final result - if isinstance(result["data"], models.User): + if isinstance(result["data"], models.Credentials): result["result"] = result["data"] return result @@ -120,11 +130,12 @@ class AuthManagerFlowManager(data_entry_flow.FlowManager): modules = await self.auth_manager.async_get_enabled_mfa(user) if modules: + flow.credential = credentials flow.user = user flow.available_mfa_modules = modules return await flow.async_step_select_mfa_module() - result["result"] = await self.auth_manager.async_get_or_create_user(credentials) + result["result"] = credentials return result @@ -156,7 +167,7 @@ class AuthManager: return list(self._mfa_modules.values()) def get_auth_provider( - self, provider_type: str, provider_id: str + self, provider_type: str, provider_id: Optional[str] ) -> Optional[AuthProvider]: """Return an auth provider, None if not found.""" return self._providers.get((provider_type, provider_id)) @@ -367,6 +378,7 @@ class AuthManager: client_icon: Optional[str] = None, token_type: Optional[str] = None, access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + credential: Optional[models.Credentials] = None, ) -> models.RefreshToken: """Create a new refresh token for a user.""" if not user.is_active: @@ -415,6 +427,7 @@ class AuthManager: client_icon, token_type, access_token_expiration, + credential, ) async def async_get_refresh_token( @@ -440,6 +453,8 @@ class AuthManager: self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None ) -> str: """Create a new access token.""" + self.async_validate_refresh_token(refresh_token, remote_ip) + self._store.async_log_refresh_token_usage(refresh_token, remote_ip) now = dt_util.utcnow() @@ -453,6 +468,40 @@ class AuthManager: algorithm="HS256", ).decode() + @callback + def _async_resolve_provider( + self, refresh_token: models.RefreshToken + ) -> Optional[AuthProvider]: + """Get the auth provider for the given refresh token. + + Raises an exception if the expected provider is no longer available or return + None if no provider was expected for this refresh token. + """ + if refresh_token.credential is None: + return None + + provider = self.get_auth_provider( + refresh_token.credential.auth_provider_type, + refresh_token.credential.auth_provider_id, + ) + if provider is None: + raise InvalidProvider( + f"Auth provider {refresh_token.credential.auth_provider_type}, {refresh_token.credential.auth_provider_id} not available" + ) + return provider + + @callback + def async_validate_refresh_token( + self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None + ) -> None: + """Validate that a refresh token is usable. + + Will raise InvalidAuthError on errors. + """ + provider = self._async_resolve_provider(refresh_token) + if provider: + provider.async_validate_refresh_token(refresh_token, remote_ip) + async def async_validate_access_token( self, token: str ) -> Optional[models.RefreshToken]: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 57ec9ee63dc..724f1c86722 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -208,6 +208,7 @@ class AuthStore: client_icon: Optional[str] = None, token_type: str = models.TOKEN_TYPE_NORMAL, access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + credential: Optional[models.Credentials] = None, ) -> models.RefreshToken: """Create a new token for a user.""" kwargs: Dict[str, Any] = { @@ -215,6 +216,7 @@ class AuthStore: "client_id": client_id, "token_type": token_type, "access_token_expiration": access_token_expiration, + "credential": credential, } if client_name: kwargs["client_name"] = client_name @@ -309,6 +311,7 @@ class AuthStore: users: Dict[str, models.User] = OrderedDict() groups: Dict[str, models.Group] = OrderedDict() + credentials: Dict[str, models.Credentials] = OrderedDict() # Soft-migrating data as we load. We are going to make sure we have a # read only group and an admin group. There are two states that we can @@ -415,15 +418,15 @@ class AuthStore: ) for cred_dict in data["credentials"]: - users[cred_dict["user_id"]].credentials.append( - models.Credentials( - id=cred_dict["id"], - is_new=False, - auth_provider_type=cred_dict["auth_provider_type"], - auth_provider_id=cred_dict["auth_provider_id"], - data=cred_dict["data"], - ) + credential = models.Credentials( + id=cred_dict["id"], + is_new=False, + auth_provider_type=cred_dict["auth_provider_type"], + auth_provider_id=cred_dict["auth_provider_id"], + data=cred_dict["data"], ) + credentials[cred_dict["id"]] = credential + users[cred_dict["user_id"]].credentials.append(credential) for rt_dict in data["refresh_tokens"]: # Filter out the old keys that don't have jwt_key (pre-0.76) @@ -469,6 +472,8 @@ class AuthStore: jwt_key=rt_dict["jwt_key"], last_used_at=last_used_at, last_used_ip=rt_dict.get("last_used_ip"), + credential=credentials.get(rt_dict.get("credential_id")), + version=rt_dict.get("version"), ) users[rt_dict["user_id"]].refresh_tokens[token.id] = token @@ -542,6 +547,10 @@ class AuthStore: if refresh_token.last_used_at else None, "last_used_ip": refresh_token.last_used_ip, + "credential_id": refresh_token.credential.id + if refresh_token.credential + else None, + "version": refresh_token.version, } for user in self._users.values() for refresh_token in user.refresh_tokens.values() diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 6e4b189bf74..f29f5f8fcc2 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,4 +1,6 @@ """Pluggable auth modules for Home Assistant.""" +from __future__ import annotations + import importlib import logging import types @@ -66,7 +68,7 @@ class MultiFactorAuthModule: """Return a voluptuous schema to define mfa auth module's input.""" raise NotImplementedError - async def async_setup_flow(self, user_id: str) -> "SetupFlow": + async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 359f79ce6ce..4a6faef96c0 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -198,7 +198,7 @@ class TotpSetupFlow(SetupFlow): errors: Dict[str, str] = {} if user_input: - verified = await self.hass.async_add_executor_job( # type: ignore + verified = await self.hass.async_add_executor_job( pyotp.TOTP(self._ota_secret).verify, user_input["code"] ) if verified: diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 5a838cfc805..4cc67b2ebd4 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -6,6 +6,7 @@ import uuid import attr +from homeassistant.const import __version__ from homeassistant.util import dt as dt_util from . import permissions as perm_mdl @@ -106,6 +107,10 @@ class RefreshToken: last_used_at: Optional[datetime] = attr.ib(default=None) last_used_ip: Optional[str] = attr.ib(default=None) + credential: Optional["Credentials"] = attr.ib(default=None) + + version: Optional[str] = attr.ib(default=__version__) + @attr.s(slots=True) class Credentials: diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 1fe59346b00..2afe1333c6a 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -1,4 +1,6 @@ """Auth providers for Home Assistant.""" +from __future__ import annotations + import importlib import logging import types @@ -16,7 +18,7 @@ from homeassistant.util.decorator import Registry from ..auth_store import AuthStore from ..const import MFA_SESSION_EXPIRATION -from ..models import Credentials, User, UserMeta +from ..models import Credentials, RefreshToken, User, UserMeta _LOGGER = logging.getLogger(__name__) DATA_REQS = "auth_prov_reqs_processed" @@ -92,7 +94,7 @@ class AuthProvider: # Implement by extending class - async def async_login_flow(self, context: Optional[Dict]) -> "LoginFlow": + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. @@ -117,6 +119,16 @@ class AuthProvider: async def async_initialize(self) -> None: """Initialize the auth provider.""" + @callback + def async_validate_refresh_token( + self, refresh_token: RefreshToken, remote_ip: Optional[str] = None + ) -> None: + """Verify a refresh token is still valid. + + Optional hook for an auth provider to verify validity of a refresh token. + Should raise InvalidAuthError on errors. + """ + async def auth_provider_from_config( hass: HomeAssistant, store: AuthStore, config: Dict[str, Any] @@ -182,6 +194,7 @@ class LoginFlow(data_entry_flow.FlowHandler): self.created_at = dt_util.utcnow() self.invalid_mfa_times = 0 self.user: Optional[User] = None + self.credential: Optional[Credentials] = None async def async_step_init( self, user_input: Optional[Dict[str, str]] = None @@ -222,6 +235,7 @@ class LoginFlow(data_entry_flow.FlowHandler): self, user_input: Optional[Dict[str, str]] = None ) -> Dict[str, Any]: """Handle the step of mfa validation.""" + assert self.credential assert self.user errors = {} @@ -257,7 +271,7 @@ class LoginFlow(data_entry_flow.FlowHandler): return self.async_abort(reason="too_many_retry") if not errors: - return await self.async_finish(self.user) + return await self.async_finish(self.credential) description_placeholders: Dict[str, Optional[str]] = { "mfa_module_name": auth_module.name, diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index d194d8119d1..b2b19221979 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -8,12 +8,12 @@ from typing import Any, Dict, Optional, cast import voluptuous as vol +from homeassistant.const import CONF_COMMAND from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta -CONF_COMMAND = "command" CONF_ARGS = "args" CONF_META = "meta" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 70e2f5403cd..c66ffa7332e 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,4 +1,6 @@ """Home Assistant auth provider.""" +from __future__ import annotations + import asyncio import base64 from collections import OrderedDict @@ -31,7 +33,7 @@ CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) @callback -def async_get_provider(hass: HomeAssistant) -> "HassAuthProvider": +def async_get_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the provider.""" for prv in hass.auth.auth_providers: if prv.type == "homeassistant": diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 15ba1dfc14c..ba96fa285f1 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -8,13 +8,12 @@ from typing import Any, Dict, Optional, cast import voluptuous as vol -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow -from .. import AuthManager -from ..models import Credentials, User, UserMeta +from ..models import Credentials, UserMeta AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" @@ -30,23 +29,6 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -async def async_validate_password(hass: HomeAssistant, password: str) -> Optional[User]: - """Return a user if password is valid. None if not.""" - auth = cast(AuthManager, hass.auth) # type: ignore - providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE) - if not providers: - raise ValueError("Legacy API password provider not found") - - try: - provider = cast(LegacyApiPasswordAuthProvider, providers[0]) - provider.async_validate_login(password) - return await auth.async_get_or_create_user( - await provider.async_get_or_create_credentials({}) - ) - except InvalidAuthError: - return None - - @AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE) class LegacyApiPasswordAuthProvider(AuthProvider): """An auth provider support legacy api_password.""" diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 0cf79c3cc95..2afdbf98196 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -3,7 +3,14 @@ It shows list of users if access from trusted network. Abort login flow if not access from trusted network. """ -from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network +from ipaddress import ( + IPv4Address, + IPv4Network, + IPv6Address, + IPv6Network, + ip_address, + ip_network, +) from typing import Any, Dict, List, Optional, Union, cast import voluptuous as vol @@ -13,7 +20,8 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow -from ..models import Credentials, UserMeta +from .. import InvalidAuthError +from ..models import Credentials, RefreshToken, UserMeta IPAddress = Union[IPv4Address, IPv6Address] IPNetwork = Union[IPv4Network, IPv6Network] @@ -46,10 +54,6 @@ CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( ) -class InvalidAuthError(HomeAssistantError): - """Raised when try to access from untrusted networks.""" - - class InvalidUserError(HomeAssistantError): """Raised when try to login as invalid user.""" @@ -163,6 +167,17 @@ class TrustedNetworksAuthProvider(AuthProvider): ): raise InvalidAuthError("Not in trusted_networks") + @callback + def async_validate_refresh_token( + self, refresh_token: RefreshToken, remote_ip: Optional[str] = None + ) -> None: + """Verify a refresh token is still valid.""" + if remote_ip is None: + raise InvalidAuthError( + "Unknown remote ip can't be used for trusted network provider." + ) + self.async_validate_access(ip_address(remote_ip)) + class TrustedNetworksLoginFlow(LoginFlow): """Handler for the login flow.""" diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0f5bda7fbf2..fd2d580a879 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -17,6 +17,7 @@ from homeassistant import config as conf_util, config_entries, core, loader from homeassistant.components import http from homeassistant.const import REQUIRED_NEXT_PYTHON_DATE, REQUIRED_NEXT_PYTHON_VER from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import area_registry, device_registry, entity_registry from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ( DATA_SETUP, @@ -510,10 +511,12 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains - # Kick off loading the registries. They don't need to be awaited. - asyncio.create_task(hass.helpers.device_registry.async_get_registry()) - asyncio.create_task(hass.helpers.entity_registry.async_get_registry()) - asyncio.create_task(hass.helpers.area_registry.async_get_registry()) + # Load the registries + await asyncio.gather( + device_registry.async_load(hass), + entity_registry.async_load(hass), + area_registry.async_load(hass), + ) # Start setup if stage_1_domains: diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 529e3ff7189..20c0624742c 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DATE, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_TIME, CONF_PASSWORD, @@ -32,7 +33,6 @@ SERVICE_SETTINGS = "change_setting" SERVICE_CAPTURE_IMAGE = "capture_image" SERVICE_TRIGGER_AUTOMATION = "trigger_automation" -ATTR_DEVICE_ID = "device_id" ATTR_DEVICE_NAME = "device_name" ATTR_DEVICE_TYPE = "device_type" ATTR_EVENT_CODE = "event_code" diff --git a/homeassistant/components/abode/translations/it.json b/homeassistant/components/abode/translations/it.json index a3e5aa4d7a8..6cb571df8e5 100644 --- a/homeassistant/components/abode/translations/it.json +++ b/homeassistant/components/abode/translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "La riautenticazione ha avuto successo", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { diff --git a/homeassistant/components/abode/translations/ko.json b/homeassistant/components/abode/translations/ko.json index 06c301550b4..a9756447adf 100644 --- a/homeassistant/components/abode/translations/ko.json +++ b/homeassistant/components/abode/translations/ko.json @@ -1,9 +1,20 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 Abode \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c" + } + }, "user": { "data": { "password": "\ube44\ubc00\ubc88\ud638", diff --git a/homeassistant/components/abode/translations/nl.json b/homeassistant/components/abode/translations/nl.json index 9177b1deb7c..7b6a8b5aace 100644 --- a/homeassistant/components/abode/translations/nl.json +++ b/homeassistant/components/abode/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Herauthenticatie was succesvol", "single_instance_allowed": "Slechts een enkele configuratie van Abode is toegestaan." }, "error": { @@ -12,9 +13,14 @@ "mfa": { "data": { "mfa_code": "MFA-code (6-cijfers)" - } + }, + "title": "Voer uw MFA-code voor Abode in" }, "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "E-mail" + }, "title": "Vul uw Abode-inloggegevens in" }, "user": { diff --git a/homeassistant/components/abode/translations/ru.json b/homeassistant/components/abode/translations/ru.json index 04efaa6e519..f3804a840ab 100644 --- a/homeassistant/components/abode/translations/ru.json +++ b/homeassistant/components/abode/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_mfa_code": "\u041d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434 MFA." }, "step": { diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index e8dbe921d77..60fdd48c8f4 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -17,6 +17,7 @@ from homeassistant.components.weather import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, CONCENTRATION_PARTS_PER_CUBIC_METER, DEVICE_CLASS_TEMPERATURE, LENGTH_FEET, @@ -33,7 +34,6 @@ from homeassistant.const import ( ) ATTRIBUTION = "Data provided by AccuWeather" -ATTR_ICON = "icon" ATTR_FORECAST = CONF_FORECAST = "forecast" ATTR_LABEL = "label" ATTR_UNIT_IMPERIAL = "Imperial" diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 6ccd6a4f10b..b03c0e51018 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -2,7 +2,7 @@ "domain": "accuweather", "name": "AccuWeather", "documentation": "https://www.home-assistant.io/integrations/accuweather/", - "requirements": ["accuweather==0.0.11"], + "requirements": ["accuweather==0.1.0"], "codeowners": ["@bieniu"], "config_flow": true, "quality_scale": "platinum" diff --git a/homeassistant/components/accuweather/translations/ko.json b/homeassistant/components/accuweather/translations/ko.json index b04778c8cb2..2f0a01e094b 100644 --- a/homeassistant/components/accuweather/translations/ko.json +++ b/homeassistant/components/accuweather/translations/ko.json @@ -1,9 +1,29 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { + "data": { + "api_key": "API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\uc774\ub984" + }, "description": "\uad6c\uc131\uc5d0 \ub300\ud55c \ub3c4\uc6c0\uc774 \ud544\uc694\ud55c \uacbd\uc6b0 \ub2e4\uc74c\uc744 \ucc38\uc870\ud574\uc8fc\uc138\uc694:\nhttps://www.home-assistant.io/integrations/accuweather/\n\n\uc77c\ubd80 \uc13c\uc11c\ub294 \uae30\ubcf8\uc801\uc73c\ub85c \ud65c\uc131\ud654\ub418\uc5b4 \uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc5f0\ub3d9 \uad6c\uc131 \ud6c4 \uad6c\uc131\uc694\uc18c \ub808\uc9c0\uc2a4\ud2b8\ub9ac\uc5d0\uc11c \ud65c\uc131\ud654\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\n\uc77c\uae30\uc608\ubcf4\ub294 \uae30\ubcf8\uc801\uc73c\ub85c \ud65c\uc131\ud654\ub418\uc5b4 \uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc5f0\ub3d9 \uc635\uc158\uc5d0\uc11c \ud65c\uc131\ud654\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." } } + }, + "options": { + "step": { + "user": { + "description": "\ubb34\ub8cc \ubc84\uc804\uc758 AccuWeather API \ud0a4\ub85c \uc77c\uae30\uc608\ubcf4\ub97c \ud65c\uc131\ud654\ud55c \uacbd\uc6b0 \uc81c\ud55c\uc0ac\ud56d\uc73c\ub85c \uc778\ud574 \uc5c5\ub370\uc774\ud2b8\ub294 40 \ubd84\uc774 \uc544\ub2cc 80 \ubd84\ub9c8\ub2e4 \uc218\ud589\ub429\ub2c8\ub2e4." + } + } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/nl.json b/homeassistant/components/accuweather/translations/nl.json index ff0d81f94d3..4bf5f9fce45 100644 --- a/homeassistant/components/accuweather/translations/nl.json +++ b/homeassistant/components/accuweather/translations/nl.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_api_key": "API-sleutel", "requests_exceeded": "Het toegestane aantal verzoeken aan de Accuweather API is overschreden. U moet wachten of de API-sleutel wijzigen." }, diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index f947f3fe0c0..101f7cbd615 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( CONF_FILENAME, CONF_NAME, + CONF_TIMEOUT, STATE_OFF, STATE_ON, STATE_UNKNOWN, @@ -17,7 +18,6 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_TIMEOUT = "timeout" CONF_WRITE_TIMEOUT = "write_timeout" DEFAULT_NAME = "Acer Projector" @@ -74,7 +74,6 @@ class AcerSwitch(SwitchEntity): def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): """Init of the Acer projector.""" - self.ser = serial.Serial( port=serial_port, timeout=timeout, write_timeout=write_timeout, **kwargs ) @@ -90,7 +89,6 @@ class AcerSwitch(SwitchEntity): def _write_read(self, msg): """Write to the projector and read the return.""" - ret = "" # Sometimes the projector won't answer for no reason or the projector # was disconnected during runtime. diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index b325e2c944a..15f9716db47 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -32,7 +32,7 @@ class AcmedaBase(entity.Entity): device.id, remove_config_entry_id=self.registry_entry.config_entry_id ) - await self.async_remove() + await self.async_remove(force_remove=True) async def async_added_to_hass(self): """Entity has been added to hass.""" diff --git a/homeassistant/components/acmeda/translations/ko.json b/homeassistant/components/acmeda/translations/ko.json index 345628eef02..098d3a952f5 100644 --- a/homeassistant/components/acmeda/translations/ko.json +++ b/homeassistant/components/acmeda/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/acmeda/translations/nl.json b/homeassistant/components/acmeda/translations/nl.json index 470e0f8f698..aac926ec048 100644 --- a/homeassistant/components/acmeda/translations/nl.json +++ b/homeassistant/components/acmeda/translations/nl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/adguard/translations/ko.json b/homeassistant/components/adguard/translations/ko.json index e17bca1f0a2..b14e627ca56 100644 --- a/homeassistant/components/adguard/translations/ko.json +++ b/homeassistant/components/adguard/translations/ko.json @@ -2,7 +2,10 @@ "config": { "abort": { "existing_instance_updated": "\uae30\uc874 \uad6c\uc131\uc744 \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 AdGuard Home \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "hassio_confirm": { @@ -14,9 +17,9 @@ "host": "\ud638\uc2a4\ud2b8", "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "ssl": "AdGuard Home \uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4", + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", - "verify_ssl": "AdGuard Home \uc740 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "description": "\ubaa8\ub2c8\ud130\ub9c1 \ubc0f \uc81c\uc5b4\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d AdGuard Home \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694." } diff --git a/homeassistant/components/advantage_air/translations/ko.json b/homeassistant/components/advantage_air/translations/ko.json new file mode 100644 index 00000000000..444d8d38285 --- /dev/null +++ b/homeassistant/components/advantage_air/translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "ip_address": "IP \uc8fc\uc18c", + "port": "\ud3ec\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/translations/nl.json b/homeassistant/components/advantage_air/translations/nl.json index 95395d24bca..3206c7a3165 100644 --- a/homeassistant/components/advantage_air/translations/nl.json +++ b/homeassistant/components/advantage_air/translations/nl.json @@ -3,9 +3,13 @@ "abort": { "already_configured": "Apparaat is al geconfigureerd" }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, "step": { "user": { "data": { + "ip_address": "IP-adres", "port": "Poort" }, "description": "Maak verbinding met de API van uw Advantage Air-tablet voor wandmontage.", diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py new file mode 100644 index 00000000000..58b1a3b10f0 --- /dev/null +++ b/homeassistant/components/aemet/__init__.py @@ -0,0 +1,61 @@ +"""The AEMET OpenData component.""" +import asyncio +import logging + +from aemet_opendata.interface import AEMET + +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 .const import COMPONENTS, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR +from .weather_update_coordinator import WeatherUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the AEMET OpenData component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up AEMET OpenData as config entry.""" + name = config_entry.data[CONF_NAME] + api_key = config_entry.data[CONF_API_KEY] + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + + aemet = AEMET(api_key) + weather_coordinator = WeatherUpdateCoordinator(hass, aemet, latitude, longitude) + + await weather_coordinator.async_refresh() + + hass.data[DOMAIN][config_entry.entry_id] = { + ENTRY_NAME: name, + ENTRY_WEATHER_COORDINATOR: weather_coordinator, + } + + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in COMPONENTS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aemet/abstract_aemet_sensor.py b/homeassistant/components/aemet/abstract_aemet_sensor.py new file mode 100644 index 00000000000..6b7c3c69fee --- /dev/null +++ b/homeassistant/components/aemet/abstract_aemet_sensor.py @@ -0,0 +1,57 @@ +"""Abstraction form AEMET OpenData sensors.""" +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, SENSOR_DEVICE_CLASS, SENSOR_NAME, SENSOR_UNIT +from .weather_update_coordinator import WeatherUpdateCoordinator + + +class AbstractAemetSensor(CoordinatorEntity): + """Abstract class for an AEMET OpenData sensor.""" + + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + coordinator: WeatherUpdateCoordinator, + ): + """Initialize the sensor.""" + super().__init__(coordinator) + self._name = name + self._unique_id = unique_id + self._sensor_type = sensor_type + self._sensor_name = sensor_configuration[SENSOR_NAME] + self._unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) + self._device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {self._sensor_name}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def device_class(self): + """Return the device_class.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py new file mode 100644 index 00000000000..27f389660a8 --- /dev/null +++ b/homeassistant/components/aemet/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for AEMET OpenData.""" +from aemet_opendata import AEMET +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_NAME +from .const import DOMAIN # pylint:disable=unused-import + + +class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for AEMET OpenData.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + latitude = user_input[CONF_LATITUDE] + longitude = user_input[CONF_LONGITUDE] + + await self.async_set_unique_id(f"{latitude}-{longitude}") + self._abort_if_unique_id_configured() + + api_online = await _is_aemet_api_online(self.hass, user_input[CONF_API_KEY]) + if not api_online: + errors["base"] = "invalid_api_key" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + +async def _is_aemet_api_online(hass, api_key): + aemet = AEMET(api_key) + return await hass.async_add_executor_job( + aemet.get_conventional_observation_stations, False + ) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py new file mode 100644 index 00000000000..13b9d944bf0 --- /dev/null +++ b/homeassistant/components/aemet/const.py @@ -0,0 +1,326 @@ +"""Constant values for the AEMET OpenData component.""" + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, +) +from homeassistant.const import ( + DEGREE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + PERCENTAGE, + PRECIPITATION_MILLIMETERS_PER_HOUR, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) + +ATTRIBUTION = "Powered by AEMET OpenData" +COMPONENTS = ["sensor", "weather"] +DEFAULT_NAME = "AEMET" +DOMAIN = "aemet" +ENTRY_NAME = "name" +ENTRY_WEATHER_COORDINATOR = "weather_coordinator" +UPDATE_LISTENER = "update_listener" +SENSOR_NAME = "sensor_name" +SENSOR_UNIT = "sensor_unit" +SENSOR_DEVICE_CLASS = "sensor_device_class" + +ATTR_API_CONDITION = "condition" +ATTR_API_FORECAST_DAILY = "forecast-daily" +ATTR_API_FORECAST_HOURLY = "forecast-hourly" +ATTR_API_HUMIDITY = "humidity" +ATTR_API_PRESSURE = "pressure" +ATTR_API_RAIN = "rain" +ATTR_API_RAIN_PROB = "rain-probability" +ATTR_API_SNOW = "snow" +ATTR_API_SNOW_PROB = "snow-probability" +ATTR_API_STATION_ID = "station-id" +ATTR_API_STATION_NAME = "station-name" +ATTR_API_STATION_TIMESTAMP = "station-timestamp" +ATTR_API_STORM_PROB = "storm-probability" +ATTR_API_TEMPERATURE = "temperature" +ATTR_API_TEMPERATURE_FEELING = "temperature-feeling" +ATTR_API_TOWN_ID = "town-id" +ATTR_API_TOWN_NAME = "town-name" +ATTR_API_TOWN_TIMESTAMP = "town-timestamp" +ATTR_API_WIND_BEARING = "wind-bearing" +ATTR_API_WIND_MAX_SPEED = "wind-max-speed" +ATTR_API_WIND_SPEED = "wind-speed" + +CONDITIONS_MAP = { + ATTR_CONDITION_CLEAR_NIGHT: { + "11n", # Despejado (de noche) + }, + ATTR_CONDITION_CLOUDY: { + "14", # Nuboso + "14n", # Nuboso (de noche) + "15", # Muy nuboso + "15n", # Muy nuboso (de noche) + "16", # Cubierto + "16n", # Cubierto (de noche) + "17", # Nubes altas + "17n", # Nubes altas (de noche) + }, + ATTR_CONDITION_FOG: { + "81", # Niebla + "81n", # Niebla (de noche) + "82", # Bruma - Neblina + "82n", # Bruma - Neblina (de noche) + }, + ATTR_CONDITION_LIGHTNING: { + "51", # Intervalos nubosos con tormenta + "51n", # Intervalos nubosos con tormenta (de noche) + "52", # Nuboso con tormenta + "52n", # Nuboso con tormenta (de noche) + "53", # Muy nuboso con tormenta + "53n", # Muy nuboso con tormenta (de noche) + "54", # Cubierto con tormenta + "54n", # Cubierto con tormenta (de noche) + }, + ATTR_CONDITION_LIGHTNING_RAINY: { + "61", # Intervalos nubosos con tormenta y lluvia escasa + "61n", # Intervalos nubosos con tormenta y lluvia escasa (de noche) + "62", # Nuboso con tormenta y lluvia escasa + "62n", # Nuboso con tormenta y lluvia escasa (de noche) + "63", # Muy nuboso con tormenta y lluvia escasa + "63n", # Muy nuboso con tormenta y lluvia escasa (de noche) + "64", # Cubierto con tormenta y lluvia escasa + "64n", # Cubierto con tormenta y lluvia escasa (de noche) + }, + ATTR_CONDITION_PARTLYCLOUDY: { + "12", # Poco nuboso + "12n", # Poco nuboso (de noche) + "13", # Intervalos nubosos + "13n", # Intervalos nubosos (de noche) + }, + ATTR_CONDITION_POURING: { + "27", # Chubascos + "27n", # Chubascos (de noche) + }, + ATTR_CONDITION_RAINY: { + "23", # Intervalos nubosos con lluvia + "23n", # Intervalos nubosos con lluvia (de noche) + "24", # Nuboso con lluvia + "24n", # Nuboso con lluvia (de noche) + "25", # Muy nuboso con lluvia + "25n", # Muy nuboso con lluvia (de noche) + "26", # Cubierto con lluvia + "26n", # Cubierto con lluvia (de noche) + "43", # Intervalos nubosos con lluvia escasa + "43n", # Intervalos nubosos con lluvia escasa (de noche) + "44", # Nuboso con lluvia escasa + "44n", # Nuboso con lluvia escasa (de noche) + "45", # Muy nuboso con lluvia escasa + "45n", # Muy nuboso con lluvia escasa (de noche) + "46", # Cubierto con lluvia escasa + "46n", # Cubierto con lluvia escasa (de noche) + }, + ATTR_CONDITION_SNOWY: { + "33", # Intervalos nubosos con nieve + "33n", # Intervalos nubosos con nieve (de noche) + "34", # Nuboso con nieve + "34n", # Nuboso con nieve (de noche) + "35", # Muy nuboso con nieve + "35n", # Muy nuboso con nieve (de noche) + "36", # Cubierto con nieve + "36n", # Cubierto con nieve (de noche) + "71", # Intervalos nubosos con nieve escasa + "71n", # Intervalos nubosos con nieve escasa (de noche) + "72", # Nuboso con nieve escasa + "72n", # Nuboso con nieve escasa (de noche) + "73", # Muy nuboso con nieve escasa + "73n", # Muy nuboso con nieve escasa (de noche) + "74", # Cubierto con nieve escasa + "74n", # Cubierto con nieve escasa (de noche) + }, + ATTR_CONDITION_SUNNY: { + "11", # Despejado + }, +} + +FORECAST_MONITORED_CONDITIONS = [ + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, +] +MONITORED_CONDITIONS = [ + ATTR_API_CONDITION, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_RAIN, + ATTR_API_RAIN_PROB, + ATTR_API_SNOW, + ATTR_API_SNOW_PROB, + ATTR_API_STATION_ID, + ATTR_API_STATION_NAME, + ATTR_API_STATION_TIMESTAMP, + ATTR_API_STORM_PROB, + ATTR_API_TEMPERATURE, + ATTR_API_TEMPERATURE_FEELING, + ATTR_API_TOWN_ID, + ATTR_API_TOWN_NAME, + ATTR_API_TOWN_TIMESTAMP, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_MAX_SPEED, + ATTR_API_WIND_SPEED, +] + +FORECAST_MODE_DAILY = "daily" +FORECAST_MODE_HOURLY = "hourly" +FORECAST_MODES = [ + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +] +FORECAST_MODE_ATTR_API = { + FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, + FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, +} + +FORECAST_SENSOR_TYPES = { + ATTR_FORECAST_CONDITION: { + SENSOR_NAME: "Condition", + }, + ATTR_FORECAST_PRECIPITATION: { + SENSOR_NAME: "Precipitation", + SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, + }, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: { + SENSOR_NAME: "Precipitation probability", + SENSOR_UNIT: PERCENTAGE, + }, + ATTR_FORECAST_TEMP: { + SENSOR_NAME: "Temperature", + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + }, + ATTR_FORECAST_TEMP_LOW: { + SENSOR_NAME: "Temperature Low", + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + }, + ATTR_FORECAST_TIME: { + SENSOR_NAME: "Time", + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + }, + ATTR_FORECAST_WIND_BEARING: { + SENSOR_NAME: "Wind bearing", + SENSOR_UNIT: DEGREE, + }, + ATTR_FORECAST_WIND_SPEED: { + SENSOR_NAME: "Wind speed", + SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, + }, +} +WEATHER_SENSOR_TYPES = { + ATTR_API_CONDITION: { + SENSOR_NAME: "Condition", + }, + ATTR_API_HUMIDITY: { + SENSOR_NAME: "Humidity", + SENSOR_UNIT: PERCENTAGE, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + }, + ATTR_API_PRESSURE: { + SENSOR_NAME: "Pressure", + SENSOR_UNIT: PRESSURE_HPA, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + }, + ATTR_API_RAIN: { + SENSOR_NAME: "Rain", + SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, + }, + ATTR_API_RAIN_PROB: { + SENSOR_NAME: "Rain probability", + SENSOR_UNIT: PERCENTAGE, + }, + ATTR_API_SNOW: { + SENSOR_NAME: "Snow", + SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, + }, + ATTR_API_SNOW_PROB: { + SENSOR_NAME: "Snow probability", + SENSOR_UNIT: PERCENTAGE, + }, + ATTR_API_STATION_ID: { + SENSOR_NAME: "Station ID", + }, + ATTR_API_STATION_NAME: { + SENSOR_NAME: "Station name", + }, + ATTR_API_STATION_TIMESTAMP: { + SENSOR_NAME: "Station timestamp", + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + }, + ATTR_API_STORM_PROB: { + SENSOR_NAME: "Storm probability", + SENSOR_UNIT: PERCENTAGE, + }, + ATTR_API_TEMPERATURE: { + SENSOR_NAME: "Temperature", + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + }, + ATTR_API_TEMPERATURE_FEELING: { + SENSOR_NAME: "Temperature feeling", + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + }, + ATTR_API_TOWN_ID: { + SENSOR_NAME: "Town ID", + }, + ATTR_API_TOWN_NAME: { + SENSOR_NAME: "Town name", + }, + ATTR_API_TOWN_TIMESTAMP: { + SENSOR_NAME: "Town timestamp", + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + }, + ATTR_API_WIND_BEARING: { + SENSOR_NAME: "Wind bearing", + SENSOR_UNIT: DEGREE, + }, + ATTR_API_WIND_MAX_SPEED: { + SENSOR_NAME: "Wind max speed", + SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, + }, + ATTR_API_WIND_SPEED: { + SENSOR_NAME: "Wind speed", + SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, + }, +} + +WIND_BEARING_MAP = { + "C": None, + "N": 0.0, + "NE": 45.0, + "E": 90.0, + "SE": 135.0, + "S": 180.0, + "SO": 225.0, + "O": 270.0, + "NO": 315.0, +} diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json new file mode 100644 index 00000000000..eb5dc295f29 --- /dev/null +++ b/homeassistant/components/aemet/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "aemet", + "name": "AEMET OpenData", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aemet", + "requirements": ["AEMET-OpenData==0.1.8"], + "codeowners": ["@noltari"] +} diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py new file mode 100644 index 00000000000..b57de1ce890 --- /dev/null +++ b/homeassistant/components/aemet/sensor.py @@ -0,0 +1,114 @@ +"""Support for the AEMET OpenData service.""" +from .abstract_aemet_sensor import AbstractAemetSensor +from .const import ( + DOMAIN, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, + FORECAST_MODE_ATTR_API, + FORECAST_MODE_DAILY, + FORECAST_MODES, + FORECAST_MONITORED_CONDITIONS, + FORECAST_SENSOR_TYPES, + MONITORED_CONDITIONS, + WEATHER_SENSOR_TYPES, +) +from .weather_update_coordinator import WeatherUpdateCoordinator + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up AEMET OpenData sensor entities based on a config entry.""" + domain_data = hass.data[DOMAIN][config_entry.entry_id] + name = domain_data[ENTRY_NAME] + weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + + weather_sensor_types = WEATHER_SENSOR_TYPES + forecast_sensor_types = FORECAST_SENSOR_TYPES + + entities = [] + for sensor_type in MONITORED_CONDITIONS: + unique_id = f"{config_entry.unique_id}-{sensor_type}" + entities.append( + AemetSensor( + name, + unique_id, + sensor_type, + weather_sensor_types[sensor_type], + weather_coordinator, + ) + ) + + for mode in FORECAST_MODES: + name = f"{domain_data[ENTRY_NAME]} {mode}" + + for sensor_type in FORECAST_MONITORED_CONDITIONS: + unique_id = f"{config_entry.unique_id}-forecast-{mode}-{sensor_type}" + entities.append( + AemetForecastSensor( + f"{name} Forecast", + unique_id, + sensor_type, + forecast_sensor_types[sensor_type], + weather_coordinator, + mode, + ) + ) + + async_add_entities(entities) + + +class AemetSensor(AbstractAemetSensor): + """Implementation of an AEMET OpenData sensor.""" + + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + weather_coordinator: WeatherUpdateCoordinator, + ): + """Initialize the sensor.""" + super().__init__( + name, unique_id, sensor_type, sensor_configuration, weather_coordinator + ) + self._weather_coordinator = weather_coordinator + + @property + def state(self): + """Return the state of the device.""" + return self._weather_coordinator.data.get(self._sensor_type) + + +class AemetForecastSensor(AbstractAemetSensor): + """Implementation of an AEMET OpenData forecast sensor.""" + + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + weather_coordinator: WeatherUpdateCoordinator, + forecast_mode, + ): + """Initialize the sensor.""" + super().__init__( + name, unique_id, sensor_type, sensor_configuration, weather_coordinator + ) + self._weather_coordinator = weather_coordinator + self._forecast_mode = forecast_mode + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._forecast_mode == FORECAST_MODE_DAILY + + @property + def state(self): + """Return the state of the device.""" + forecasts = self._weather_coordinator.data.get( + FORECAST_MODE_ATTR_API[self._forecast_mode] + ) + if forecasts: + return forecasts[0].get(self._sensor_type) + return None diff --git a/homeassistant/components/aemet/strings.json b/homeassistant/components/aemet/strings.json new file mode 100644 index 00000000000..a25a503bade --- /dev/null +++ b/homeassistant/components/aemet/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "name": "Name of the integration" + }, + "description": "Set up AEMET OpenData integration. To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} diff --git a/homeassistant/components/aemet/translations/ca.json b/homeassistant/components/aemet/translations/ca.json new file mode 100644 index 00000000000..85b22e72d76 --- /dev/null +++ b/homeassistant/components/aemet/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada" + }, + "error": { + "invalid_api_key": "Clau API inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom de la integraci\u00f3" + }, + "description": "Configura la integraci\u00f3 d'AEMET OpenData. Per generar la clau API, v\u00e9s a https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/cs.json b/homeassistant/components/aemet/translations/cs.json new file mode 100644 index 00000000000..d892d4c6dc3 --- /dev/null +++ b/homeassistant/components/aemet/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Um\u00edst\u011bn\u00ed je ji\u017e nastaveno" + }, + "error": { + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "N\u00e1zev integrace" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/de.json b/homeassistant/components/aemet/translations/de.json new file mode 100644 index 00000000000..d7254aea92f --- /dev/null +++ b/homeassistant/components/aemet/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Standort ist bereits konfiguriert" + }, + "error": { + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/en.json b/homeassistant/components/aemet/translations/en.json new file mode 100644 index 00000000000..60e7f5f2ec2 --- /dev/null +++ b/homeassistant/components/aemet/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Location is already configured" + }, + "error": { + "invalid_api_key": "Invalid API key" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name of the integration" + }, + "description": "Set up AEMET OpenData integration. To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/es.json b/homeassistant/components/aemet/translations/es.json new file mode 100644 index 00000000000..ffe4d524754 --- /dev/null +++ b/homeassistant/components/aemet/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada" + }, + "error": { + "invalid_api_key": "Clave API no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "description": "Configurar la integraci\u00f3n de AEMET OpenData. Para generar la clave API, ve a https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/et.json b/homeassistant/components/aemet/translations/et.json new file mode 100644 index 00000000000..bc0a26179d5 --- /dev/null +++ b/homeassistant/components/aemet/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Asukoht on juba m\u00e4\u00e4ratud" + }, + "error": { + "invalid_api_key": "Vale API v\u00f5ti" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti", + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad", + "name": "Sidumise nimi" + }, + "description": "Seadista AEMET OpenData sidumine. API v\u00f5tme loomiseks mine aadressile https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/fr.json b/homeassistant/components/aemet/translations/fr.json new file mode 100644 index 00000000000..bb1e792aa5e --- /dev/null +++ b/homeassistant/components/aemet/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_api_key": "Cl\u00e9 API invalide" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom de l'int\u00e9gration" + }, + "description": "Configurez l'int\u00e9gration AEMET OpenData. Pour g\u00e9n\u00e9rer la cl\u00e9 API, acc\u00e9dez \u00e0 https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/it.json b/homeassistant/components/aemet/translations/it.json new file mode 100644 index 00000000000..112630028b9 --- /dev/null +++ b/homeassistant/components/aemet/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "La posizione \u00e8 gi\u00e0 configurata" + }, + "error": { + "invalid_api_key": "Chiave API non valida" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome dell'integrazione" + }, + "description": "Imposta l'integrazione di AEMET OpenData. Per generare la chiave API, vai su https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ko.json b/homeassistant/components/aemet/translations/ko.json new file mode 100644 index 00000000000..edfb023a88b --- /dev/null +++ b/homeassistant/components/aemet/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc774\ub984" + }, + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/nl.json b/homeassistant/components/aemet/translations/nl.json new file mode 100644 index 00000000000..02415dde1e6 --- /dev/null +++ b/homeassistant/components/aemet/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Locatie is al geconfigureerd." + }, + "error": { + "invalid_api_key": "Ongeldige API-sleutel" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam van de integratie" + }, + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/no.json b/homeassistant/components/aemet/translations/no.json new file mode 100644 index 00000000000..48cbc9916ca --- /dev/null +++ b/homeassistant/components/aemet/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Plasseringen er allerede konfigurert" + }, + "error": { + "invalid_api_key": "Ugyldig API-n\u00f8kkel" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navnet p\u00e5 integrasjonen" + }, + "description": "Sett opp AEMET OpenData-integrasjon. For \u00e5 generere API-n\u00f8kkel, g\u00e5 til https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/pl.json b/homeassistant/components/aemet/translations/pl.json new file mode 100644 index 00000000000..2c5c24fae2a --- /dev/null +++ b/homeassistant/components/aemet/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + }, + "error": { + "invalid_api_key": "Nieprawid\u0142owy klucz API" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa integracji" + }, + "description": "Skonfiguruj integracj\u0119 AEMET OpenData. Aby wygenerowa\u0107 klucz API, przejd\u017a do https://opendata.aemet.es/centrodedescargas/altaUsuario", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ru.json b/homeassistant/components/aemet/translations/ru.json new file mode 100644 index 00000000000..4da9a032d2b --- /dev/null +++ b/homeassistant/components/aemet/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "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.", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/zh-Hant.json b/homeassistant/components/aemet/translations/zh-Hant.json new file mode 100644 index 00000000000..75b251ae2ff --- /dev/null +++ b/homeassistant/components/aemet/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u6574\u5408\u540d\u7a31" + }, + "description": "\u6b32\u8a2d\u5b9a AEMET OpenData \u6574\u5408\u3002\u8acb\u81f3 https://opendata.aemet.es/centrodedescargas/altaUsuario \u7522\u751f API \u5bc6\u9470", + "title": "AEMET OpenData" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py new file mode 100644 index 00000000000..e54a297cc09 --- /dev/null +++ b/homeassistant/components/aemet/weather.py @@ -0,0 +1,113 @@ +"""Support for the AEMET OpenData service.""" +from homeassistant.components.weather import WeatherEntity +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_API_CONDITION, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_SPEED, + ATTRIBUTION, + DOMAIN, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, + FORECAST_MODE_ATTR_API, + FORECAST_MODE_DAILY, + FORECAST_MODES, +) +from .weather_update_coordinator import WeatherUpdateCoordinator + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up AEMET OpenData weather entity based on a config entry.""" + domain_data = hass.data[DOMAIN][config_entry.entry_id] + weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + + entities = [] + for mode in FORECAST_MODES: + name = f"{domain_data[ENTRY_NAME]} {mode}" + unique_id = f"{config_entry.unique_id} {mode}" + entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) + + if entities: + async_add_entities(entities, False) + + +class AemetWeather(CoordinatorEntity, WeatherEntity): + """Implementation of an AEMET OpenData sensor.""" + + def __init__( + self, + name, + unique_id, + coordinator: WeatherUpdateCoordinator, + forecast_mode, + ): + """Initialize the sensor.""" + super().__init__(coordinator) + self._name = name + self._unique_id = unique_id + self._forecast_mode = forecast_mode + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def condition(self): + """Return the current condition.""" + return self.coordinator.data[ATTR_API_CONDITION] + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._forecast_mode == FORECAST_MODE_DAILY + + @property + def forecast(self): + """Return the forecast array.""" + return self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data[ATTR_API_HUMIDITY] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data[ATTR_API_PRESSURE] + + @property + def temperature(self): + """Return the temperature.""" + return self.coordinator.data[ATTR_API_TEMPERATURE] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def wind_bearing(self): + """Return the temperature.""" + return self.coordinator.data[ATTR_API_WIND_BEARING] + + @property + def wind_speed(self): + """Return the temperature.""" + return self.coordinator.data[ATTR_API_WIND_SPEED] diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py new file mode 100644 index 00000000000..6a06b1dd391 --- /dev/null +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -0,0 +1,637 @@ +"""Weather data coordinator for the AEMET OpenData service.""" +from dataclasses import dataclass, field +from datetime import timedelta +import logging + +from aemet_opendata.const import ( + AEMET_ATTR_DATE, + AEMET_ATTR_DAY, + AEMET_ATTR_DIRECTION, + AEMET_ATTR_ELABORATED, + AEMET_ATTR_FORECAST, + AEMET_ATTR_HUMIDITY, + AEMET_ATTR_ID, + AEMET_ATTR_IDEMA, + AEMET_ATTR_MAX, + AEMET_ATTR_MIN, + AEMET_ATTR_NAME, + AEMET_ATTR_PRECIPITATION, + AEMET_ATTR_PRECIPITATION_PROBABILITY, + AEMET_ATTR_SKY_STATE, + AEMET_ATTR_SNOW, + AEMET_ATTR_SNOW_PROBABILITY, + AEMET_ATTR_SPEED, + AEMET_ATTR_STATION_DATE, + AEMET_ATTR_STATION_HUMIDITY, + AEMET_ATTR_STATION_LOCATION, + AEMET_ATTR_STATION_PRESSURE_SEA, + AEMET_ATTR_STATION_TEMPERATURE, + AEMET_ATTR_STORM_PROBABILITY, + AEMET_ATTR_TEMPERATURE, + AEMET_ATTR_TEMPERATURE_FEELING, + AEMET_ATTR_WIND, + AEMET_ATTR_WIND_GUST, + ATTR_DATA, +) +from aemet_opendata.helpers import ( + get_forecast_day_value, + get_forecast_hour_value, + get_forecast_interval_value, +) +import async_timeout + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_API_CONDITION, + ATTR_API_FORECAST_DAILY, + ATTR_API_FORECAST_HOURLY, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_RAIN, + ATTR_API_RAIN_PROB, + ATTR_API_SNOW, + ATTR_API_SNOW_PROB, + ATTR_API_STATION_ID, + ATTR_API_STATION_NAME, + ATTR_API_STATION_TIMESTAMP, + ATTR_API_STORM_PROB, + ATTR_API_TEMPERATURE, + ATTR_API_TEMPERATURE_FEELING, + ATTR_API_TOWN_ID, + ATTR_API_TOWN_NAME, + ATTR_API_TOWN_TIMESTAMP, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_MAX_SPEED, + ATTR_API_WIND_SPEED, + CONDITIONS_MAP, + DOMAIN, + WIND_BEARING_MAP, +) + +_LOGGER = logging.getLogger(__name__) + +WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) + + +def format_condition(condition: str) -> str: + """Return condition from dict CONDITIONS_MAP.""" + for key, value in CONDITIONS_MAP.items(): + if condition in value: + return key + _LOGGER.error('condition "%s" not found in CONDITIONS_MAP', condition) + return condition + + +def format_float(value) -> float: + """Try converting string to float.""" + try: + return float(value) + except ValueError: + return None + + +def format_int(value) -> int: + """Try converting string to int.""" + try: + return int(value) + except ValueError: + return None + + +class TownNotFound(UpdateFailed): + """Raised when town is not found.""" + + +class WeatherUpdateCoordinator(DataUpdateCoordinator): + """Weather data update coordinator.""" + + def __init__(self, hass, aemet, latitude, longitude): + """Initialize coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + ) + + self._aemet = aemet + self._station = None + self._town = None + self._latitude = latitude + self._longitude = longitude + self._data = { + "daily": None, + "hourly": None, + "station": None, + } + + async def _async_update_data(self): + data = {} + with async_timeout.timeout(120): + weather_response = await self._get_aemet_weather() + data = self._convert_weather_response(weather_response) + return data + + async def _get_aemet_weather(self): + """Poll weather data from AEMET OpenData.""" + weather = await self.hass.async_add_executor_job(self._get_weather_and_forecast) + return weather + + def _get_weather_station(self): + if not self._station: + self._station = ( + self._aemet.get_conventional_observation_station_by_coordinates( + self._latitude, self._longitude + ) + ) + if self._station: + _LOGGER.debug( + "station found for coordinates [%s, %s]: %s", + self._latitude, + self._longitude, + self._station, + ) + if not self._station: + _LOGGER.debug( + "station not found for coordinates [%s, %s]", + self._latitude, + self._longitude, + ) + return self._station + + def _get_weather_town(self): + if not self._town: + self._town = self._aemet.get_town_by_coordinates( + self._latitude, self._longitude + ) + if self._town: + _LOGGER.debug( + "town found for coordinates [%s, %s]: %s", + self._latitude, + self._longitude, + self._town, + ) + if not self._town: + _LOGGER.error( + "town not found for coordinates [%s, %s]", + self._latitude, + self._longitude, + ) + raise TownNotFound + return self._town + + def _get_weather_and_forecast(self): + """Get weather and forecast data from AEMET OpenData.""" + + self._get_weather_town() + + daily = self._aemet.get_specific_forecast_town_daily(self._town[AEMET_ATTR_ID]) + if not daily: + _LOGGER.error( + 'error fetching daily data for town "%s"', self._town[AEMET_ATTR_ID] + ) + + hourly = self._aemet.get_specific_forecast_town_hourly( + self._town[AEMET_ATTR_ID] + ) + if not hourly: + _LOGGER.error( + 'error fetching hourly data for town "%s"', self._town[AEMET_ATTR_ID] + ) + + station = None + if self._get_weather_station(): + station = self._aemet.get_conventional_observation_station_data( + self._station[AEMET_ATTR_IDEMA] + ) + if not station: + _LOGGER.error( + 'error fetching data for station "%s"', + self._station[AEMET_ATTR_IDEMA], + ) + + if daily: + self._data["daily"] = daily + if hourly: + self._data["hourly"] = hourly + if station: + self._data["station"] = station + + return AemetWeather( + self._data["daily"], + self._data["hourly"], + self._data["station"], + ) + + def _convert_weather_response(self, weather_response): + """Format the weather response correctly.""" + if not weather_response or not weather_response.hourly: + return None + + elaborated = dt_util.parse_datetime( + weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_ELABORATED] + ) + now = dt_util.now() + hour = now.hour + + # Get current day + day = None + for cur_day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ + AEMET_ATTR_DAY + ]: + cur_day_date = dt_util.parse_datetime(cur_day[AEMET_ATTR_DATE]) + if now.date() == cur_day_date.date(): + day = cur_day + break + + # Get station data + station_data = None + if weather_response.station: + station_data = weather_response.station[ATTR_DATA][-1] + + condition = None + humidity = None + pressure = None + rain = None + rain_prob = None + snow = None + snow_prob = None + station_id = None + station_name = None + station_timestamp = None + storm_prob = None + temperature = None + temperature_feeling = None + town_id = None + town_name = None + town_timestamp = dt_util.as_utc(elaborated) + wind_bearing = None + wind_max_speed = None + wind_speed = None + + # Get weather values + if day: + condition = self._get_condition(day, hour) + humidity = self._get_humidity(day, hour) + rain = self._get_rain(day, hour) + rain_prob = self._get_rain_prob(day, hour) + snow = self._get_snow(day, hour) + snow_prob = self._get_snow_prob(day, hour) + station_id = self._get_station_id() + station_name = self._get_station_name() + storm_prob = self._get_storm_prob(day, hour) + temperature = self._get_temperature(day, hour) + temperature_feeling = self._get_temperature_feeling(day, hour) + town_id = self._get_town_id() + town_name = self._get_town_name() + wind_bearing = self._get_wind_bearing(day, hour) + wind_max_speed = self._get_wind_max_speed(day, hour) + wind_speed = self._get_wind_speed(day, hour) + + # Overwrite weather values with closest station data (if present) + if station_data: + if AEMET_ATTR_STATION_DATE in station_data: + station_dt = dt_util.parse_datetime( + station_data[AEMET_ATTR_STATION_DATE] + "Z" + ) + station_timestamp = dt_util.as_utc(station_dt).isoformat() + if AEMET_ATTR_STATION_HUMIDITY in station_data: + humidity = format_float(station_data[AEMET_ATTR_STATION_HUMIDITY]) + if AEMET_ATTR_STATION_PRESSURE_SEA in station_data: + pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE_SEA]) + if AEMET_ATTR_STATION_TEMPERATURE in station_data: + temperature = format_float(station_data[AEMET_ATTR_STATION_TEMPERATURE]) + + # Get forecast from weather data + forecast_daily = self._get_daily_forecast_from_weather_response( + weather_response, now + ) + forecast_hourly = self._get_hourly_forecast_from_weather_response( + weather_response, now + ) + + return { + ATTR_API_CONDITION: condition, + ATTR_API_FORECAST_DAILY: forecast_daily, + ATTR_API_FORECAST_HOURLY: forecast_hourly, + ATTR_API_HUMIDITY: humidity, + ATTR_API_TEMPERATURE: temperature, + ATTR_API_TEMPERATURE_FEELING: temperature_feeling, + ATTR_API_PRESSURE: pressure, + ATTR_API_RAIN: rain, + ATTR_API_RAIN_PROB: rain_prob, + ATTR_API_SNOW: snow, + ATTR_API_SNOW_PROB: snow_prob, + ATTR_API_STATION_ID: station_id, + ATTR_API_STATION_NAME: station_name, + ATTR_API_STATION_TIMESTAMP: station_timestamp, + ATTR_API_STORM_PROB: storm_prob, + ATTR_API_TOWN_ID: town_id, + ATTR_API_TOWN_NAME: town_name, + ATTR_API_TOWN_TIMESTAMP: town_timestamp, + ATTR_API_WIND_BEARING: wind_bearing, + ATTR_API_WIND_MAX_SPEED: wind_max_speed, + ATTR_API_WIND_SPEED: wind_speed, + } + + def _get_daily_forecast_from_weather_response(self, weather_response, now): + if weather_response.daily: + parse = False + forecast = [] + for day in weather_response.daily[ATTR_DATA][0][AEMET_ATTR_FORECAST][ + AEMET_ATTR_DAY + ]: + day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) + if now.date() == day_date.date(): + parse = True + if parse: + cur_forecast = self._convert_forecast_day(day_date, day) + if cur_forecast: + forecast.append(cur_forecast) + return forecast + return None + + def _get_hourly_forecast_from_weather_response(self, weather_response, now): + if weather_response.hourly: + parse = False + hour = now.hour + forecast = [] + for day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ + AEMET_ATTR_DAY + ]: + day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) + hour_start = 0 + if now.date() == day_date.date(): + parse = True + hour_start = now.hour + if parse: + for hour in range(hour_start, 24): + cur_forecast = self._convert_forecast_hour(day_date, day, hour) + if cur_forecast: + forecast.append(cur_forecast) + return forecast + return None + + def _convert_forecast_day(self, date, day): + condition = self._get_condition_day(day) + if not condition: + return None + + return { + ATTR_FORECAST_CONDITION: condition, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( + day + ), + ATTR_FORECAST_TEMP: self._get_temperature_day(day), + ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), + ATTR_FORECAST_TIME: dt_util.as_utc(date), + ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), + ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), + } + + def _convert_forecast_hour(self, date, day, hour): + condition = self._get_condition(day, hour) + if not condition: + return None + + forecast_dt = date.replace(hour=hour, minute=0, second=0) + + return { + ATTR_FORECAST_CONDITION: condition, + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( + day, hour + ), + ATTR_FORECAST_TEMP: self._get_temperature(day, hour), + ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt), + ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), + ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), + } + + 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 + + if round(rain_value + snow_value, 1) == 0: + return None + return round(rain_value + snow_value, 1) + + 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 + + if rain_value == 0 and snow_value == 0: + return None + return max(rain_value, snow_value) + + @staticmethod + def _get_condition(day_data, hour): + """Get weather condition (hour) from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_SKY_STATE], hour) + if val: + return format_condition(val) + return None + + @staticmethod + def _get_condition_day(day_data): + """Get weather condition (day) from weather data.""" + val = get_forecast_day_value(day_data[AEMET_ATTR_SKY_STATE]) + if val: + return format_condition(val) + return None + + @staticmethod + def _get_humidity(day_data, hour): + """Get humidity from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_HUMIDITY], hour) + if val: + return format_int(val) + return None + + @staticmethod + def _get_precipitation_prob_day(day_data): + """Get humidity from weather data.""" + val = get_forecast_day_value(day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY]) + if val: + return format_int(val) + return None + + @staticmethod + def _get_rain(day_data, hour): + """Get rain from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_PRECIPITATION], hour) + if val: + return format_float(val) + return None + + @staticmethod + def _get_rain_prob(day_data, hour): + """Get rain probability from weather data.""" + val = get_forecast_interval_value( + day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY], hour + ) + if val: + return format_int(val) + return None + + @staticmethod + def _get_snow(day_data, hour): + """Get snow from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_SNOW], hour) + if val: + return format_float(val) + return None + + @staticmethod + def _get_snow_prob(day_data, hour): + """Get snow probability from weather data.""" + val = get_forecast_interval_value(day_data[AEMET_ATTR_SNOW_PROBABILITY], hour) + if val: + return format_int(val) + return None + + def _get_station_id(self): + """Get station ID from weather data.""" + if self._station: + return self._station[AEMET_ATTR_IDEMA] + return None + + def _get_station_name(self): + """Get station name from weather data.""" + if self._station: + return self._station[AEMET_ATTR_STATION_LOCATION] + return None + + @staticmethod + def _get_storm_prob(day_data, hour): + """Get storm probability from weather data.""" + val = get_forecast_interval_value(day_data[AEMET_ATTR_STORM_PROBABILITY], hour) + if val: + return format_int(val) + return None + + @staticmethod + def _get_temperature(day_data, hour): + """Get temperature (hour) from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE], hour) + if val: + return format_int(val) + return None + + @staticmethod + def _get_temperature_day(day_data): + """Get temperature (day) from weather data.""" + val = get_forecast_day_value( + day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MAX + ) + if val: + return format_int(val) + return None + + @staticmethod + def _get_temperature_low_day(day_data): + """Get temperature (day) from weather data.""" + val = get_forecast_day_value( + day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MIN + ) + if val: + return format_int(val) + return None + + @staticmethod + def _get_temperature_feeling(day_data, hour): + """Get temperature from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour) + if val: + return format_int(val) + return None + + def _get_town_id(self): + """Get town ID from weather data.""" + if self._town: + return self._town[AEMET_ATTR_ID] + return None + + def _get_town_name(self): + """Get town name from weather data.""" + if self._town: + return self._town[AEMET_ATTR_NAME] + return None + + @staticmethod + def _get_wind_bearing(day_data, hour): + """Get wind bearing (hour) from weather data.""" + val = get_forecast_hour_value( + day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION + )[0] + if val in WIND_BEARING_MAP: + return WIND_BEARING_MAP[val] + _LOGGER.error("%s not found in Wind Bearing map", val) + return None + + @staticmethod + def _get_wind_bearing_day(day_data): + """Get wind bearing (day) from weather data.""" + val = get_forecast_day_value( + day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION + ) + if val in WIND_BEARING_MAP: + return WIND_BEARING_MAP[val] + _LOGGER.error("%s not found in Wind Bearing map", val) + return None + + @staticmethod + def _get_wind_max_speed(day_data, hour): + """Get wind max speed from weather data.""" + val = get_forecast_hour_value(day_data[AEMET_ATTR_WIND_GUST], hour) + if val: + return format_int(val) + return None + + @staticmethod + def _get_wind_speed(day_data, hour): + """Get wind speed (hour) from weather data.""" + val = get_forecast_hour_value( + day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_SPEED + )[0] + if val: + return format_int(val) + return None + + @staticmethod + def _get_wind_speed_day(day_data): + """Get wind speed (day) from weather data.""" + val = get_forecast_day_value(day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_SPEED) + if val: + return format_int(val) + return None + + +@dataclass +class AemetWeather: + """Class to harmonize weather data model.""" + + daily: dict = field(default_factory=dict) + hourly: dict = field(default_factory=dict) + station: dict = field(default_factory=dict) diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index 9448b8d3123..15ef58ced7e 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -1,6 +1,4 @@ """Config flow to configure Agent devices.""" -import logging - from agent import AgentConnectionError, AgentError from agent.a import Agent import voluptuous as vol @@ -13,7 +11,6 @@ from .const import DOMAIN, SERVER_URL # pylint:disable=unused-import from .helpers import generate_url DEFAULT_PORT = 8090 -_LOGGER = logging.getLogger(__name__) class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/agent_dvr/translations/ko.json b/homeassistant/components/agent_dvr/translations/ko.json index 30b96c00b63..add0b917100 100644 --- a/homeassistant/components/agent_dvr/translations/ko.json +++ b/homeassistant/components/agent_dvr/translations/ko.json @@ -4,7 +4,8 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/agent_dvr/translations/nl.json b/homeassistant/components/agent_dvr/translations/nl.json index ad625c169c8..7c679f66c11 100644 --- a/homeassistant/components/agent_dvr/translations/nl.json +++ b/homeassistant/components/agent_dvr/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "already_in_progress": "De configuratiestroom is al aan de gang", "cannot_connect": "Kan geen verbinding maken" }, "step": { diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 48423d08e69..52c9208854a 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -2,7 +2,10 @@ from datetime import timedelta import logging -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, +) from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -13,7 +16,6 @@ from homeassistant.helpers.entity_component import EntityComponent _LOGGER = logging.getLogger(__name__) ATTR_AQI = "air_quality_index" -ATTR_ATTRIBUTION = "attribution" ATTR_CO2 = "carbon_dioxide" ATTR_CO = "carbon_monoxide" ATTR_N2O = "nitrogen_oxide" diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 9d6b46f82e5..6a9c23624f0 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,4 +1,4 @@ -"""The Airly component.""" +"""The Airly integration.""" import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py index e43a76b3418..4c4c239a84b 100644 --- a/homeassistant/components/airly/air_quality.py +++ b/homeassistant/components/airly/air_quality.py @@ -19,14 +19,13 @@ from .const import ( ATTR_API_PM25, ATTR_API_PM25_LIMIT, ATTR_API_PM25_PERCENT, + ATTRIBUTION, DEFAULT_NAME, DOMAIN, + LABEL_ADVICE, MANUFACTURER, ) -ATTRIBUTION = "Data provided by Airly" - -LABEL_ADVICE = "advice" LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description" LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index b4711b50dd2..b8d2270c3c4 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -1,4 +1,5 @@ """Constants for Airly integration.""" + ATTR_API_ADVICE = "ADVICE" ATTR_API_CAQI = "CAQI" ATTR_API_CAQI_DESCRIPTION = "DESCRIPTION" @@ -13,9 +14,15 @@ ATTR_API_PM25_LIMIT = "PM25_LIMIT" ATTR_API_PM25_PERCENT = "PM25_PERCENT" ATTR_API_PRESSURE = "PRESSURE" ATTR_API_TEMPERATURE = "TEMPERATURE" + +ATTR_LABEL = "label" +ATTR_UNIT = "unit" + +ATTRIBUTION = "Data provided by Airly" CONF_USE_NEAREST = "use_nearest" DEFAULT_NAME = "Airly" DOMAIN = "airly" +LABEL_ADVICE = "advice" MANUFACTURER = "Airly sp. z o.o." MAX_REQUESTS_PER_DAY = 100 NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 420d11a5963..789dbbb4657 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -2,6 +2,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, DEVICE_CLASS_HUMIDITY, @@ -18,17 +19,14 @@ from .const import ( ATTR_API_PM1, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, + ATTR_LABEL, + ATTR_UNIT, + ATTRIBUTION, DEFAULT_NAME, DOMAIN, MANUFACTURER, ) -ATTRIBUTION = "Data provided by Airly" - -ATTR_ICON = "icon" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" - PARALLEL_UPDATES = 1 SENSOR_TYPES = { diff --git a/homeassistant/components/airly/translations/ca.json b/homeassistant/components/airly/translations/ca.json index 95400de23b4..e76cec94f4c 100644 --- a/homeassistant/components/airly/translations/ca.json +++ b/homeassistant/components/airly/translations/ca.json @@ -22,7 +22,9 @@ }, "system_health": { "info": { - "can_reach_server": "Servidor d'Airly accessible" + "can_reach_server": "Servidor d'Airly accessible", + "requests_per_day": "Sol\u00b7licituds per dia permeses", + "requests_remaining": "Sol\u00b7licituds permeses restants" } } } \ No newline at end of file diff --git a/homeassistant/components/airly/translations/en.json b/homeassistant/components/airly/translations/en.json index 720f68f8349..0a5426c87d8 100644 --- a/homeassistant/components/airly/translations/en.json +++ b/homeassistant/components/airly/translations/en.json @@ -22,7 +22,9 @@ }, "system_health": { "info": { - "can_reach_server": "Reach Airly server" + "can_reach_server": "Reach Airly server", + "requests_per_day": "Allowed requests per day", + "requests_remaining": "Remaining allowed requests" } } } \ No newline at end of file diff --git a/homeassistant/components/airly/translations/et.json b/homeassistant/components/airly/translations/et.json index 8cbfd138257..c5c9359c67f 100644 --- a/homeassistant/components/airly/translations/et.json +++ b/homeassistant/components/airly/translations/et.json @@ -22,7 +22,9 @@ }, "system_health": { "info": { - "can_reach_server": "\u00dchendus Airly serveriga" + "can_reach_server": "\u00dchendus Airly serveriga", + "requests_per_day": "Lubatud taotlusi p\u00e4evas", + "requests_remaining": "J\u00e4\u00e4nud lubatud taotlusi" } } } \ No newline at end of file diff --git a/homeassistant/components/airly/translations/ko.json b/homeassistant/components/airly/translations/ko.json index a0b20ed8c44..95981ea5eb1 100644 --- a/homeassistant/components/airly/translations/ko.json +++ b/homeassistant/components/airly/translations/ko.json @@ -1,9 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc88c\ud45c\uc5d0 \ub300\ud55c Airly \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "wrong_location": "\uc774 \uc9c0\uc5ed\uc5d0\ub294 Airly \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc774 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { @@ -12,7 +13,7 @@ "api_key": "API \ud0a4", "latitude": "\uc704\ub3c4", "longitude": "\uacbd\ub3c4", - "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc758 \uc774\ub984" + "name": "\uc774\ub984" }, "description": "Airly \uacf5\uae30 \ud488\uc9c8 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. API \ud0a4\ub97c \uc0dd\uc131\ud558\ub824\uba74 https://developer.airly.eu/register \ub85c \uc774\ub3d9\ud574\uc8fc\uc138\uc694", "title": "Airly" diff --git a/homeassistant/components/airly/translations/ru.json b/homeassistant/components/airly/translations/ru.json index b1469af787e..41ca90a8c02 100644 --- a/homeassistant/components/airly/translations/ru.json +++ b/homeassistant/components/airly/translations/ru.json @@ -22,7 +22,9 @@ }, "system_health": { "info": { - "can_reach_server": "\u0414\u043e\u0441\u0442\u0443\u043f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 Airly" + "can_reach_server": "\u0414\u043e\u0441\u0442\u0443\u043f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 Airly", + "requests_per_day": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u0432 \u0434\u0435\u043d\u044c", + "requests_remaining": "\u0421\u0447\u0451\u0442\u0447\u0438\u043a \u043e\u0441\u0442\u0430\u0432\u0448\u0438\u0445\u0441\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432" } } } \ No newline at end of file diff --git a/homeassistant/components/airly/translations/zh-Hant.json b/homeassistant/components/airly/translations/zh-Hant.json index 4d60b158c4c..19ef2ae7532 100644 --- a/homeassistant/components/airly/translations/zh-Hant.json +++ b/homeassistant/components/airly/translations/zh-Hant.json @@ -22,7 +22,9 @@ }, "system_health": { "info": { - "can_reach_server": "\u9023\u7dda Airly \u4f3a\u670d\u5668" + "can_reach_server": "\u9023\u7dda Airly \u4f3a\u670d\u5668", + "requests_per_day": "\u6bcf\u65e5\u5141\u8a31\u7684\u8acb\u6c42", + "requests_remaining": "\u5176\u9918\u5141\u8a31\u7684\u8acb\u6c42" } } } \ No newline at end of file diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index fed6def2b36..4488098701f 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -2,6 +2,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) @@ -20,7 +21,6 @@ from .const import ( ATTRIBUTION = "Data provided by AirNow" -ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" diff --git a/homeassistant/components/airnow/translations/ko.json b/homeassistant/components/airnow/translations/ko.json new file mode 100644 index 00000000000..6da62dffa2c --- /dev/null +++ b/homeassistant/components/airnow/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/nl.json b/homeassistant/components/airnow/translations/nl.json new file mode 100644 index 00000000000..011498269f8 --- /dev/null +++ b/homeassistant/components/airnow/translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "invalid_location": "Geen resultaten gevonden voor die locatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "title": "AirNow" + } + } + }, + "title": "AirNow" +} \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/ru.json b/homeassistant/components/airnow/translations/ru.json index 650633cc816..9667accb7c4 100644 --- a/homeassistant/components/airnow/translations/ru.json +++ b/homeassistant/components/airnow/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_location": "\u0414\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u043e\u0432 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\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/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 956b168a665..3a88243b0b9 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -37,7 +38,7 @@ from .const import ( CONF_INTEGRATION_TYPE, DATA_COORDINATOR, DOMAIN, - INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_NODE_PRO, LOGGER, ) @@ -145,7 +146,7 @@ def _standardize_geography_config_entry(hass, config_entry): # If the config entry data doesn't contain the integration type, add it: entry_updates["data"] = { **config_entry.data, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, } if not entry_updates: @@ -232,7 +233,6 @@ async def async_setup_entry(hass, config_entry): update_method=async_update_data, ) - hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator async_sync_geo_coordinator_update_intervals( hass, config_entry.data[CONF_API_KEY] ) @@ -262,9 +262,11 @@ async def async_setup_entry(hass, config_entry): update_method=async_update_data, ) - hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator - await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator for component in PLATFORMS: hass.async_create_task( @@ -299,10 +301,14 @@ async def async_migrate_entry(hass, config_entry): # For any geographies that remain, create a new config entry for each one: for geography in geographies: + if CONF_LATITUDE in geography: + source = "geography_by_coords" + else: + source = "geography_by_name" hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, - context={"source": "geography"}, + context={"source": source}, data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **geography}, ) ) @@ -327,7 +333,10 @@ async def async_unload_entry(hass, config_entry): remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) remove_listener() - if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + if ( + config_entry.data[CONF_INTEGRATION_TYPE] + == INTEGRATION_TYPE_GEOGRAPHY_COORDS + ): # Re-calculate the update interval period for any remaining consumers of # this API key: async_sync_geo_coordinator_update_intervals( diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index b086aeefc27..12dec114349 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -2,7 +2,12 @@ import asyncio from pyairvisual import CloudAPI, NodeSamba -from pyairvisual.errors import InvalidKeyError, NodeProError +from pyairvisual.errors import ( + AirVisualError, + InvalidKeyError, + NodeProError, + NotFoundError, +) import voluptuous as vol from homeassistant import config_entries @@ -13,20 +18,46 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_PASSWORD, CONF_SHOW_ON_MAP, + CONF_STATE, ) from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from . import async_get_geography_id from .const import ( # pylint: disable=unused-import - CONF_GEOGRAPHIES, + CONF_CITY, + CONF_COUNTRY, CONF_INTEGRATION_TYPE, DOMAIN, - INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, INTEGRATION_TYPE_NODE_PRO, LOGGER, ) +API_KEY_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string}) +GEOGRAPHY_NAME_SCHEMA = API_KEY_DATA_SCHEMA.extend( + { + vol.Required(CONF_CITY): cv.string, + vol.Required(CONF_STATE): cv.string, + vol.Required(CONF_COUNTRY): cv.string, + } +) +NODE_PRO_SCHEMA = vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_PASSWORD): cv.string} +) +PICK_INTEGRATION_TYPE_SCHEMA = vol.Schema( + { + vol.Required("type"): vol.In( + [ + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + INTEGRATION_TYPE_NODE_PRO, + ] + ) + } +) + class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an AirVisual config flow.""" @@ -36,16 +67,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the config flow.""" + self._entry_data_for_reauth = None self._geo_id = None - self._latitude = None - self._longitude = None - - self.api_key_data_schema = vol.Schema({vol.Required(CONF_API_KEY): str}) @property - def geography_schema(self): + def geography_coords_schema(self): """Return the data schema for the cloud API.""" - return self.api_key_data_schema.extend( + return API_KEY_DATA_SCHEMA.extend( { vol.Required( CONF_LATITUDE, default=self.hass.config.latitude @@ -56,24 +84,72 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - @property - def pick_integration_type_schema(self): - """Return the data schema for picking the integration type.""" - return vol.Schema( - { - vol.Required("type"): vol.In( - [INTEGRATION_TYPE_GEOGRAPHY, INTEGRATION_TYPE_NODE_PRO] - ) - } + async def _async_finish_geography(self, user_input, integration_type): + """Validate a Cloud API key.""" + websession = aiohttp_client.async_get_clientsession(self.hass) + cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) + + # If this is the first (and only the first) time we've seen this API key, check + # that it's valid: + valid_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) + valid_keys_lock = self.hass.data.setdefault( + "airvisual_checked_api_keys_lock", asyncio.Lock() ) - @property - def node_pro_schema(self): - """Return the data schema for a Node/Pro.""" - return vol.Schema( - {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_PASSWORD): str} + if integration_type == INTEGRATION_TYPE_GEOGRAPHY_COORDS: + coro = cloud_api.air_quality.nearest_city() + error_schema = self.geography_coords_schema + error_step = "geography_by_coords" + else: + coro = cloud_api.air_quality.city( + user_input[CONF_CITY], user_input[CONF_STATE], user_input[CONF_COUNTRY] + ) + error_schema = GEOGRAPHY_NAME_SCHEMA + error_step = "geography_by_name" + + async with valid_keys_lock: + if user_input[CONF_API_KEY] not in valid_keys: + 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"}, + ) + except NotFoundError: + return self.async_show_form( + step_id=error_step, + data_schema=error_schema, + 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"}, + ) + + valid_keys.add(user_input[CONF_API_KEY]) + + 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) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=f"Cloud API ({self._geo_id})", + data={**user_input, CONF_INTEGRATION_TYPE: integration_type}, ) + async def _async_init_geography(self, user_input, integration_type): + """Handle the initialization of the integration via the cloud API.""" + self._geo_id = async_get_geography_id(user_input) + await self._async_set_unique_id(self._geo_id) + self._abort_if_unique_id_configured() + return await self._async_finish_geography(user_input, integration_type) + async def _async_set_unique_id(self, unique_id): """Set the unique ID of the config flow and abort if it already exists.""" await self.async_set_unique_id(unique_id) @@ -85,73 +161,32 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return AirVisualOptionsFlowHandler(config_entry) - async def async_step_geography(self, user_input=None): - """Handle the initialization of the integration via the cloud API.""" + async def async_step_geography_by_coords(self, user_input=None): + """Handle the initialization of the cloud API based on latitude/longitude.""" if not user_input: return self.async_show_form( - step_id="geography", data_schema=self.geography_schema + step_id="geography_by_coords", data_schema=self.geography_coords_schema ) - self._geo_id = async_get_geography_id(user_input) - await self._async_set_unique_id(self._geo_id) - self._abort_if_unique_id_configured() - - # Find older config entries without unique ID: - for entry in self._async_current_entries(): - if entry.version != 1: - continue - - if any( - self._geo_id == async_get_geography_id(geography) - for geography in entry.data[CONF_GEOGRAPHIES] - ): - return self.async_abort(reason="already_configured") - - return await self.async_step_geography_finish( - user_input, "geography", self.geography_schema + return await self._async_init_geography( + user_input, INTEGRATION_TYPE_GEOGRAPHY_COORDS ) - async def async_step_geography_finish(self, user_input, error_step, error_schema): - """Validate a Cloud API key.""" - websession = aiohttp_client.async_get_clientsession(self.hass) - cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) + async def async_step_geography_by_name(self, user_input=None): + """Handle the initialization of the cloud API based on city/state/country.""" + if not user_input: + return self.async_show_form( + step_id="geography_by_name", data_schema=GEOGRAPHY_NAME_SCHEMA + ) - # If this is the first (and only the first) time we've seen this API key, check - # that it's valid: - valid_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) - valid_keys_lock = self.hass.data.setdefault( - "airvisual_checked_api_keys_lock", asyncio.Lock() - ) - - async with valid_keys_lock: - if user_input[CONF_API_KEY] not in valid_keys: - try: - await cloud_api.air_quality.nearest_city() - except InvalidKeyError: - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={CONF_API_KEY: "invalid_api_key"}, - ) - - valid_keys.add(user_input[CONF_API_KEY]) - - 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) - return self.async_abort(reason="reauth_successful") - - return self.async_create_entry( - title=f"Cloud API ({self._geo_id})", - data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY}, + return await self._async_init_geography( + user_input, INTEGRATION_TYPE_GEOGRAPHY_NAME ) async def async_step_node_pro(self, user_input=None): """Handle the initialization of the integration with a Node/Pro.""" if not user_input: - return self.async_show_form( - step_id="node_pro", data_schema=self.node_pro_schema - ) + return self.async_show_form(step_id="node_pro", data_schema=NODE_PRO_SCHEMA) await self._async_set_unique_id(user_input[CONF_IP_ADDRESS]) @@ -163,7 +198,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.error("Error connecting to Node/Pro unit: %s", err) return self.async_show_form( step_id="node_pro", - data_schema=self.node_pro_schema, + data_schema=NODE_PRO_SCHEMA, errors={CONF_IP_ADDRESS: "cannot_connect"}, ) @@ -176,39 +211,34 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data): """Handle configuration by re-auth.""" + self._entry_data_for_reauth = data self._geo_id = async_get_geography_id(data) - self._latitude = data[CONF_LATITUDE] - self._longitude = data[CONF_LONGITUDE] - return await self.async_step_reauth_confirm() async def async_step_reauth_confirm(self, user_input=None): """Handle re-auth completion.""" if not user_input: return self.async_show_form( - step_id="reauth_confirm", data_schema=self.api_key_data_schema + step_id="reauth_confirm", data_schema=API_KEY_DATA_SCHEMA ) - conf = { - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_LATITUDE: self._latitude, - CONF_LONGITUDE: self._longitude, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, - } + conf = {CONF_API_KEY: user_input[CONF_API_KEY], **self._entry_data_for_reauth} - return await self.async_step_geography_finish( - conf, "reauth_confirm", self.api_key_data_schema + return await self._async_finish_geography( + conf, self._entry_data_for_reauth[CONF_INTEGRATION_TYPE] ) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" if not user_input: return self.async_show_form( - step_id="user", data_schema=self.pick_integration_type_schema + step_id="user", data_schema=PICK_INTEGRATION_TYPE_SCHEMA ) - if user_input["type"] == INTEGRATION_TYPE_GEOGRAPHY: - return await self.async_step_geography() + if user_input["type"] == INTEGRATION_TYPE_GEOGRAPHY_COORDS: + return await self.async_step_geography_by_coords() + if user_input["type"] == INTEGRATION_TYPE_GEOGRAPHY_NAME: + return await self.async_step_geography_by_name() return await self.async_step_node_pro() diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py index a98a899b762..510ada2b68c 100644 --- a/homeassistant/components/airvisual/const.py +++ b/homeassistant/components/airvisual/const.py @@ -4,7 +4,8 @@ import logging DOMAIN = "airvisual" LOGGER = logging.getLogger(__package__) -INTEGRATION_TYPE_GEOGRAPHY = "Geographical Location" +INTEGRATION_TYPE_GEOGRAPHY_COORDS = "Geographical Location by Latitude/Longitude" +INTEGRATION_TYPE_GEOGRAPHY_NAME = "Geographical Location by Name" INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro" CONF_CITY = "city" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 49a05272488..3c1aef128ab 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,6 +1,4 @@ """Support for AirVisual air quality sensors.""" -from logging import getLogger - from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -27,11 +25,10 @@ from .const import ( CONF_INTEGRATION_TYPE, DATA_COORDINATOR, DOMAIN, - INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, ) -_LOGGER = getLogger(__name__) - ATTR_CITY = "city" ATTR_COUNTRY = "country" ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" @@ -58,14 +55,23 @@ NODE_PRO_SENSORS = [ (SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS), ] -POLLUTANT_MAPPING = { - "co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION}, - "n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, - "o3": {"label": "Ozone", "unit": CONCENTRATION_PARTS_PER_BILLION}, - "p1": {"label": "PM10", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, - "p2": {"label": "PM2.5", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, - "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, -} + +@callback +def async_get_pollutant_label(symbol): + """Get a pollutant's label based on its symbol.""" + if symbol == "co": + return "Carbon Monoxide" + if symbol == "n2": + return "Nitrogen Dioxide" + if symbol == "o3": + return "Ozone" + if symbol == "p1": + return "PM10" + if symbol == "p2": + return "PM2.5" + if symbol == "s2": + return "Sulfur Dioxide" + return symbol @callback @@ -84,11 +90,32 @@ def async_get_pollutant_level_info(value): return ("Hazardous", "mdi:biohazard") +@callback +def async_get_pollutant_unit(symbol): + """Get a pollutant's unit based on its symbol.""" + if symbol == "co": + return CONCENTRATION_PARTS_PER_MILLION + if symbol == "n2": + return CONCENTRATION_PARTS_PER_BILLION + if symbol == "o3": + return CONCENTRATION_PARTS_PER_BILLION + if symbol == "p1": + return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + if symbol == "p2": + return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + if symbol == "s2": + return CONCENTRATION_PARTS_PER_BILLION + return None + + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up AirVisual sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + if config_entry.data[CONF_INTEGRATION_TYPE] in [ + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ]: sensors = [ AirVisualGeographySensor( coordinator, @@ -173,25 +200,40 @@ class AirVisualGeographySensor(AirVisualEntity): self._state = data[f"aqi{self._locale}"] elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._state = POLLUTANT_MAPPING[symbol]["label"] + self._state = async_get_pollutant_label(symbol) self._attrs.update( { ATTR_POLLUTANT_SYMBOL: symbol, - ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]["unit"], + ATTR_POLLUTANT_UNIT: async_get_pollutant_unit(symbol), } ) - if CONF_LATITUDE in self._config_entry.data: - if self._config_entry.options[CONF_SHOW_ON_MAP]: - self._attrs[ATTR_LATITUDE] = self._config_entry.data[CONF_LATITUDE] - self._attrs[ATTR_LONGITUDE] = self._config_entry.data[CONF_LONGITUDE] - self._attrs.pop("lati", None) - self._attrs.pop("long", None) - else: - self._attrs["lati"] = self._config_entry.data[CONF_LATITUDE] - self._attrs["long"] = self._config_entry.data[CONF_LONGITUDE] - self._attrs.pop(ATTR_LATITUDE, None) - self._attrs.pop(ATTR_LONGITUDE, None) + # Displaying the geography on the map relies upon putting the latitude/longitude + # in the entity attributes with "latitude" and "longitude" as the keys. + # Conversely, we can hide the location on the map by using other keys, like + # "lati" and "long". + # + # We use any coordinates in the config entry and, in the case of a geography by + # name, we fall back to the latitude longitude provided in the coordinator data: + latitude = self._config_entry.data.get( + CONF_LATITUDE, + self.coordinator.data["location"]["coordinates"][1], + ) + longitude = self._config_entry.data.get( + CONF_LONGITUDE, + self.coordinator.data["location"]["coordinates"][0], + ) + + if self._config_entry.options[CONF_SHOW_ON_MAP]: + self._attrs[ATTR_LATITUDE] = latitude + self._attrs[ATTR_LONGITUDE] = longitude + self._attrs.pop("lati", None) + self._attrs.pop("long", None) + else: + self._attrs["lati"] = latitude + self._attrs["long"] = longitude + self._attrs.pop(ATTR_LATITUDE, None) + self._attrs.pop(ATTR_LONGITUDE, None) class AirVisualNodeProSensor(AirVisualEntity): diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 22f9c80f313..8d2dce85a17 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -1,15 +1,25 @@ { "config": { "step": { - "geography": { + "geography_by_coords": { "title": "Configure a Geography", - "description": "Use the AirVisual cloud API to monitor a geographical location.", + "description": "Use the AirVisual cloud API to monitor a latitude/longitude.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]" } }, + "geography_by_name": { + "title": "Configure a Geography", + "description": "Use the AirVisual cloud API to monitor a city/state/country.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "city": "City", + "country": "Country", + "state": "state" + } + }, "node_pro": { "title": "Configure an AirVisual Node/Pro", "description": "Monitor a personal AirVisual unit. The password can be retrieved from the unit's UI.", @@ -26,17 +36,13 @@ }, "user": { "title": "Configure AirVisual", - "description": "Pick what type of AirVisual data you want to monitor.", - "data": { - "cloud_api": "Geographical Location", - "node_pro": "AirVisual Node Pro", - "type": "Integration Type" - } + "description": "Pick what type of AirVisual data you want to monitor." } }, "error": { "general_error": "[%key:common::config_flow::error::unknown%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "location_not_found": "Location not found", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { diff --git a/homeassistant/components/airvisual/translations/cs.json b/homeassistant/components/airvisual/translations/cs.json index 5c26dcf98ef..75720b0f30b 100644 --- a/homeassistant/components/airvisual/translations/cs.json +++ b/homeassistant/components/airvisual/translations/cs.json @@ -17,6 +17,20 @@ "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" } }, + "geography_by_coords": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + } + }, + "geography_by_name": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "city": "M\u011bsto", + "country": "Zem\u011b" + } + }, "node_pro": { "data": { "ip_address": "Hostitel", diff --git a/homeassistant/components/airvisual/translations/de.json b/homeassistant/components/airvisual/translations/de.json index a16b02915ee..6e2a5f60c6f 100644 --- a/homeassistant/components/airvisual/translations/de.json +++ b/homeassistant/components/airvisual/translations/de.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "general_error": "Unerwarteter Fehler", - "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel" + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", + "location_not_found": "Standort nicht gefunden" }, "step": { "geography": { @@ -16,8 +17,28 @@ "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, + "description": "Verwende die AirVisual Cloud API, um einen geografischen Standort zu \u00fcberwachen.", "title": "Konfigurieren Sie eine Geografie" }, + "geography_by_coords": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "description": "Verwende die AirVisual Cloud API, um einen L\u00e4ngengrad/Breitengrad zu \u00fcberwachen.", + "title": "Konfiguriere einen Standort" + }, + "geography_by_name": { + "data": { + "api_key": "API-Schl\u00fcssel", + "city": "Stadt", + "country": "Land", + "state": "Bundesland" + }, + "description": "Verwende die AirVisual Cloud API, um ein(e) Stadt/Bundesland/Land zu \u00fcberwachen.", + "title": "Konfiguriere einen Standort" + }, "node_pro": { "data": { "ip_address": "Host", @@ -29,7 +50,8 @@ "reauth_confirm": { "data": { "api_key": "API-Key" - } + }, + "title": "AirVisual erneut authentifizieren" }, "user": { "data": { diff --git a/homeassistant/components/airvisual/translations/en.json b/homeassistant/components/airvisual/translations/en.json index 1a52bfb7e3b..64eb11f902c 100644 --- a/homeassistant/components/airvisual/translations/en.json +++ b/homeassistant/components/airvisual/translations/en.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Failed to connect", "general_error": "Unexpected error", - "invalid_api_key": "Invalid API key" + "invalid_api_key": "Invalid API key", + "location_not_found": "Location not found" }, "step": { "geography": { @@ -25,7 +26,17 @@ "latitude": "Latitude", "longitude": "Longitude" }, - "description": "Use the AirVisual cloud API to monitor a geographical location.", + "description": "Use the AirVisual cloud API to monitor a latitude/longitude.", + "title": "Configure a Geography" + }, + "geography_by_name": { + "data": { + "api_key": "API Key", + "city": "City", + "country": "Country", + "state": "state" + }, + "description": "Use the AirVisual cloud API to monitor a city/state/country.", "title": "Configure a Geography" }, "node_pro": { @@ -63,4 +74,4 @@ } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/es.json b/homeassistant/components/airvisual/translations/es.json index 4325f1561b9..53768f679be 100644 --- a/homeassistant/components/airvisual/translations/es.json +++ b/homeassistant/components/airvisual/translations/es.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "No se pudo conectar", "general_error": "Se ha producido un error desconocido.", - "invalid_api_key": "Se proporciona una clave API no v\u00e1lida." + "invalid_api_key": "Se proporciona una clave API no v\u00e1lida.", + "location_not_found": "Ubicaci\u00f3n no encontrada" }, "step": { "geography": { @@ -19,6 +20,25 @@ "description": "Utilizar la API en la nube de AirVisual para monitorizar una ubicaci\u00f3n geogr\u00e1fica.", "title": "Configurar una Geograf\u00eda" }, + "geography_by_coords": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Utilice la API de la nube de AirVisual para supervisar una latitud/longitud.", + "title": "Configurar una geograf\u00eda" + }, + "geography_by_name": { + "data": { + "api_key": "Clave API", + "city": "Ciudad", + "country": "Pa\u00eds", + "state": "estado" + }, + "description": "Utilice la API de la nube de AirVisual para supervisar una ciudad/estado/pa\u00eds.", + "title": "Configurar una geograf\u00eda" + }, "node_pro": { "data": { "ip_address": "Direcci\u00f3n IP/Nombre de host de la Unidad", diff --git a/homeassistant/components/airvisual/translations/et.json b/homeassistant/components/airvisual/translations/et.json index 9912dbce035..0fae2bcc57b 100644 --- a/homeassistant/components/airvisual/translations/et.json +++ b/homeassistant/components/airvisual/translations/et.json @@ -17,7 +17,7 @@ "latitude": "Laiuskraad", "longitude": "Pikkuskraad" }, - "description": "Kasutage AirVisual pilve API-t geograafilise asukoha j\u00e4lgimiseks.", + "description": "Kasuta AirVisual pilve API-t geograafilise asukoha j\u00e4lgimiseks.", "title": "Seadista Geography" }, "geography_by_coords": { diff --git a/homeassistant/components/airvisual/translations/fr.json b/homeassistant/components/airvisual/translations/fr.json index d1a0d3d511a..62c144e075d 100644 --- a/homeassistant/components/airvisual/translations/fr.json +++ b/homeassistant/components/airvisual/translations/fr.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "\u00c9chec de connexion", "general_error": "Erreur inattendue", - "invalid_api_key": "Cl\u00e9 API invalide" + "invalid_api_key": "Cl\u00e9 API invalide", + "location_not_found": "Emplacement introuvable" }, "step": { "geography": { @@ -19,6 +20,25 @@ "description": "Utilisez l'API cloud AirVisual pour surveiller une position g\u00e9ographique.", "title": "Configurer une g\u00e9ographie" }, + "geography_by_coords": { + "data": { + "api_key": "Clef d'API", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Utilisez l'API cloud AirVisual pour surveiller une latitude / longitude.", + "title": "Configurer un lieu g\u00e9ographique" + }, + "geography_by_name": { + "data": { + "api_key": "Clef d'API", + "city": "Ville", + "country": "Pays", + "state": "Etat" + }, + "description": "Utilisez l'API cloud AirVisual pour surveiller une ville / un \u00e9tat / un pays.", + "title": "Configurer un lieu g\u00e9ographique" + }, "node_pro": { "data": { "ip_address": "H\u00f4te", diff --git a/homeassistant/components/airvisual/translations/it.json b/homeassistant/components/airvisual/translations/it.json index 7a4062fbe76..3ce45ff1342 100644 --- a/homeassistant/components/airvisual/translations/it.json +++ b/homeassistant/components/airvisual/translations/it.json @@ -2,12 +2,13 @@ "config": { "abort": { "already_configured": "La posizione \u00e8 gi\u00e0 configurata o Node/Pro ID sono gi\u00e0 registrati.", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", "general_error": "Errore imprevisto", - "invalid_api_key": "Chiave API non valida" + "invalid_api_key": "Chiave API non valida", + "location_not_found": "Posizione non trovata" }, "step": { "geography": { @@ -19,6 +20,25 @@ "description": "Utilizzare l'API di AirVisual cloud per monitorare una posizione geografica.", "title": "Configurare una Geografia" }, + "geography_by_coords": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine" + }, + "description": "Usa l'API cloud di AirVisual per monitorare una latitudine/longitudine.", + "title": "Configurare un'area geografica" + }, + "geography_by_name": { + "data": { + "api_key": "Chiave API", + "city": "Citt\u00e0", + "country": "Nazione", + "state": "Stato" + }, + "description": "Usa l'API cloud di AirVisual per monitorare una citt\u00e0/stato/paese.", + "title": "Configurare un'area geografica" + }, "node_pro": { "data": { "ip_address": "Host", diff --git a/homeassistant/components/airvisual/translations/ko.json b/homeassistant/components/airvisual/translations/ko.json index d25df4c213f..8cf450e597b 100644 --- a/homeassistant/components/airvisual/translations/ko.json +++ b/homeassistant/components/airvisual/translations/ko.json @@ -1,11 +1,13 @@ { "config": { "abort": { - "already_configured": "\uc88c\ud45c\uac12 \ub610\ub294 Node/Pro ID \uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "Node/Pro ID \uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uac70\ub098 \uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "general_error": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", - "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "general_error": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "geography": { @@ -17,14 +19,31 @@ "description": "AirVisual \ud074\ub77c\uc6b0\ub4dc API \ub97c \uc0ac\uc6a9\ud558\uc5ec \uc9c0\ub9ac\uc801 \uc704\uce58\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.", "title": "\uc9c0\ub9ac\uc801 \uc704\uce58 \uad6c\uc131\ud558\uae30" }, + "geography_by_coords": { + "data": { + "api_key": "API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4" + } + }, + "geography_by_name": { + "data": { + "api_key": "API \ud0a4" + } + }, "node_pro": { "data": { - "ip_address": "\uae30\uae30 IP \uc8fc\uc18c/\ud638\uc2a4\ud2b8 \uc774\ub984", + "ip_address": "\ud638\uc2a4\ud2b8", "password": "\ube44\ubc00\ubc88\ud638" }, "description": "\uc0ac\uc6a9\uc790\uc758 AirVisual \uae30\uae30\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4. \uae30\uae30\uc758 UI \uc5d0\uc11c \ube44\ubc00\ubc88\ud638\ub97c \ucc3e\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "title": "AirVisual Node/Pro \uad6c\uc131\ud558\uae30" }, + "reauth_confirm": { + "data": { + "api_key": "API \ud0a4" + } + }, "user": { "data": { "cloud_api": "\uc9c0\ub9ac\uc801 \uc704\uce58", diff --git a/homeassistant/components/airvisual/translations/lb.json b/homeassistant/components/airvisual/translations/lb.json index 5e45098c11d..d6799ba6e37 100644 --- a/homeassistant/components/airvisual/translations/lb.json +++ b/homeassistant/components/airvisual/translations/lb.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Feeler beim verbannen", "general_error": "Onerwaarte Feeler", - "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel" + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel", + "location_not_found": "Standuert net fonnt." }, "step": { "geography": { @@ -19,6 +20,12 @@ "description": "Benotz Airvisual cloud API fir eng geografescher Lag z'iwwerwaachen.", "title": "Geografie ariichten" }, + "geography_by_name": { + "data": { + "city": "Stad", + "country": "Land" + } + }, "node_pro": { "data": { "ip_address": "Host", diff --git a/homeassistant/components/airvisual/translations/nl.json b/homeassistant/components/airvisual/translations/nl.json index 85f8be5f8e0..ecf2322c801 100644 --- a/homeassistant/components/airvisual/translations/nl.json +++ b/homeassistant/components/airvisual/translations/nl.json @@ -1,12 +1,14 @@ { "config": { "abort": { - "already_configured": "Deze co\u00f6rdinaten of Node / Pro ID zijn al geregistreerd." + "already_configured": "Deze co\u00f6rdinaten of Node / Pro ID zijn al geregistreerd.", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "cannot_connect": "Kan geen verbinding maken", "general_error": "Er is een onbekende fout opgetreden.", - "invalid_api_key": "Ongeldige API-sleutel" + "invalid_api_key": "Ongeldige API-sleutel", + "location_not_found": "Locatie niet gevonden" }, "step": { "geography": { @@ -18,6 +20,21 @@ "description": "Gebruik de AirVisual cloud API om een geografische locatie te bewaken.", "title": "Configureer een geografie" }, + "geography_by_coords": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + } + }, + "geography_by_name": { + "data": { + "api_key": "API-sleutel", + "city": "Stad", + "country": "Land" + }, + "description": "Gebruik de AirVisual-cloud-API om een stad/staat/land te bewaken." + }, "node_pro": { "data": { "ip_address": "IP adres/hostname van component", diff --git a/homeassistant/components/airvisual/translations/ru.json b/homeassistant/components/airvisual/translations/ru.json index de9e5a730fe..bc648c84bfe 100644 --- a/homeassistant/components/airvisual/translations/ru.json +++ b/homeassistant/components/airvisual/translations/ru.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "general_error": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", - "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API." + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "location_not_found": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e." }, "step": { "geography": { @@ -19,6 +20,25 @@ "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, + "geography_by_coords": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + }, + "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0448\u0438\u0440\u043e\u0442\u044b/\u0434\u043e\u043b\u0433\u043e\u0442\u044b.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "geography_by_name": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "city": "\u0413\u043e\u0440\u043e\u0434", + "country": "\u0421\u0442\u0440\u0430\u043d\u0430", + "state": "\u0448\u0442\u0430\u0442" + }, + "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, "node_pro": { "data": { "ip_address": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/airvisual/translations/sv.json b/homeassistant/components/airvisual/translations/sv.json index f375b4fc598..ecc1c397ec4 100644 --- a/homeassistant/components/airvisual/translations/sv.json +++ b/homeassistant/components/airvisual/translations/sv.json @@ -21,7 +21,8 @@ "data": { "cloud_api": "Geografisk Plats", "type": "Integrationstyp" - } + }, + "title": "Konfigurera AirVisual" } } } diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index bb5d82c52b1..5669340c2ce 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -132,14 +132,12 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" config = TRIGGER_SCHEMA(config) - from_state = None if config[CONF_TYPE] == "triggered": to_state = STATE_ALARM_TRIGGERED elif config[CONF_TYPE] == "disarmed": to_state = STATE_ALARM_DISARMED elif config[CONF_TYPE] == "arming": - from_state = STATE_ALARM_DISARMED to_state = STATE_ALARM_ARMING elif config[CONF_TYPE] == "armed_home": to_state = STATE_ALARM_ARMED_HOME @@ -153,8 +151,6 @@ async def async_attach_trigger( CONF_ENTITY_ID: config[CONF_ENTITY_ID], state_trigger.CONF_TO: to_state, } - if from_state: - state_config[state_trigger.CONF_FROM] = from_state state_config = state_trigger.TRIGGER_SCHEMA(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/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index ee1e8c1fcf6..b18f1cfb782 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,61 +1,74 @@ # Describes the format for available alarm control panel services alarm_disarm: + name: Disarm description: Send the alarm the command for disarm. + target: fields: - entity_id: - description: Name of alarm control panel to disarm. - example: "alarm_control_panel.downstairs" code: + name: Code description: An optional code to disarm the alarm control panel with. example: "1234" + selector: + text: alarm_arm_custom_bypass: + name: Arm with custom bypass description: Send arm custom bypass command. + target: fields: - entity_id: - description: Name of alarm control panel to arm custom bypass. - example: "alarm_control_panel.downstairs" code: - description: An optional code to arm custom bypass the alarm control panel with. + name: Code + description: + An optional code to arm custom bypass the alarm control panel with. example: "1234" + selector: + text: alarm_arm_home: + name: Arm home description: Send the alarm the command for arm home. + target: fields: - entity_id: - description: Name of alarm control panel to arm home. - example: "alarm_control_panel.downstairs" code: + name: Code description: An optional code to arm home the alarm control panel with. example: "1234" + selector: + text: alarm_arm_away: + name: Arm away description: Send the alarm the command for arm away. + target: fields: - entity_id: - description: Name of alarm control panel to arm away. - example: "alarm_control_panel.downstairs" code: + name: Code description: An optional code to arm away the alarm control panel with. example: "1234" + selector: + text: alarm_arm_night: + name: Arm night description: Send the alarm the command for arm night. + target: fields: - entity_id: - description: Name of alarm control panel to arm night. - example: "alarm_control_panel.downstairs" code: + name: Code description: An optional code to arm night the alarm control panel with. example: "1234" + selector: + text: alarm_trigger: + name: Trigger description: Send the alarm the command for trigger. + target: fields: - entity_id: - description: Name of alarm control panel to trigger. - example: "alarm_control_panel.downstairs" code: + name: Code description: An optional code to trigger the alarm control panel with. example: "1234" + selector: + text: diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index 1697858718d..c3e72e407c2 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -2,7 +2,7 @@ "domain": "alarmdecoder", "name": "AlarmDecoder", "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", - "requirements": ["adext==0.3"], + "requirements": ["adext==0.4.1"], "codeowners": ["@ajschmidt8"], "config_flow": true } diff --git a/homeassistant/components/alarmdecoder/translations/ko.json b/homeassistant/components/alarmdecoder/translations/ko.json index ed29a3260ef..08383d37151 100644 --- a/homeassistant/components/alarmdecoder/translations/ko.json +++ b/homeassistant/components/alarmdecoder/translations/ko.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "\uc7a5\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "create_entry": { "default": "AlarmDecoder\uc5d0 \uc131\uacf5\uc801\uc73c\ub85c \uc5f0\uacb0\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "protocol": { "data": { diff --git a/homeassistant/components/alarmdecoder/translations/nl.json b/homeassistant/components/alarmdecoder/translations/nl.json index 1af1e8d803c..1ea9cb98b56 100644 --- a/homeassistant/components/alarmdecoder/translations/nl.json +++ b/homeassistant/components/alarmdecoder/translations/nl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "AlarmDecoder-apparaat is al geconfigureerd." + "already_configured": "Apparaat is al geconfigureerd" }, "create_entry": { "default": "Succesvol verbonden met AlarmDecoder." diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 53b1a1248dc..73bea193394 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME, + CONF_REPEAT, CONF_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -33,7 +34,6 @@ DOMAIN = "alert" CONF_CAN_ACK = "can_acknowledge" CONF_NOTIFIERS = "notifiers" -CONF_REPEAT = "repeat" CONF_SKIP_FIRST = "skip_first" CONF_ALERT_MESSAGE = "message" CONF_DONE_MESSAGE = "done_message" diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml index 99530200546..5800d642b93 100644 --- a/homeassistant/components/alert/services.yaml +++ b/homeassistant/components/alert/services.yaml @@ -1,18 +1,14 @@ toggle: + name: Toggle description: Toggle alert's notifications. - fields: - entity_id: - description: Name of the alert to toggle. - example: alert.garage_door_open + target: + turn_off: + name: Turn off description: Silence alert's notifications. - fields: - entity_id: - description: Name of the alert to silence. - example: alert.garage_door_open + target: + turn_on: + name: Turn on description: Reset alert's notifications. - fields: - entity_id: - description: Name of the alert to reset. - example: alert.garage_door_open + target: diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 5180a8d55b6..70d426905e9 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -1,7 +1,12 @@ """Support for Alexa skill service end point.""" import voluptuous as vol -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_NAME +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_NAME, + CONF_PASSWORD, +) from homeassistant.helpers import config_validation as cv, entityfilter from . import flash_briefings, intent, smart_home_http @@ -14,7 +19,6 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, CONF_LOCALE, - CONF_PASSWORD, CONF_SUPPORTED_LOCALES, CONF_TEXT, CONF_TITLE, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 008870c8dd9..acfba91a933 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -46,7 +46,6 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, DATE_FORMAT, - PERCENTAGE_FAN_MAP, Inputs, ) from .errors import UnsupportedProperty @@ -668,9 +667,7 @@ class AlexaPercentageController(AlexaCapability): raise UnsupportedProperty(name) if self.entity.domain == fan.DOMAIN: - speed = self.entity.attributes.get(fan.ATTR_SPEED) - - return PERCENTAGE_FAN_MAP.get(speed, 0) + return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 if self.entity.domain == cover.DOMAIN: return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) @@ -1155,9 +1152,7 @@ class AlexaPowerLevelController(AlexaCapability): raise UnsupportedProperty(name) if self.entity.domain == fan.DOMAIN: - speed = self.entity.attributes.get(fan.ATTR_SPEED) - - return PERCENTAGE_FAN_MAP.get(speed) + return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 return None diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index a5a1cde2e15..a076fdcad9e 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,7 +1,6 @@ """Constants for the Alexa integration.""" from collections import OrderedDict -from homeassistant.components import fan from homeassistant.components.climate import const as climate from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT @@ -19,7 +18,6 @@ CONF_FILTER = "filter" CONF_ENTITY_CONFIG = "entity_config" CONF_ENDPOINT = "endpoint" CONF_LOCALE = "locale" -CONF_PASSWORD = "password" ATTR_UID = "uid" ATTR_UPDATE_DATE = "updateDate" @@ -53,10 +51,13 @@ CONF_SUPPORTED_LOCALES = ( "en-US", "es-ES", "es-MX", + "es-US", "fr-CA", "fr-FR", + "hi-IN", "it-IT", "ja-JP", + "pt-BR", ) API_TEMP_UNITS = {TEMP_FAHRENHEIT: "FAHRENHEIT", TEMP_CELSIUS: "CELSIUS"} @@ -78,13 +79,6 @@ API_THERMOSTAT_MODES = OrderedDict( API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} -PERCENTAGE_FAN_MAP = { - fan.SPEED_OFF: 0, - fan.SPEED_LOW: 33, - fan.SPEED_MEDIUM: 66, - fan.SPEED_HIGH: 100, -} - class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index b8f78705e10..50463810bbf 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -5,7 +5,7 @@ import logging import uuid from homeassistant.components import http -from homeassistant.const import HTTP_NOT_FOUND, HTTP_UNAUTHORIZED +from homeassistant.const import CONF_PASSWORD, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED from homeassistant.core import callback from homeassistant.helpers import template import homeassistant.util.dt as dt_util @@ -20,7 +20,6 @@ from .const import ( ATTR_UPDATE_DATE, CONF_AUDIO, CONF_DISPLAY_URL, - CONF_PASSWORD, CONF_TEXT, CONF_TITLE, CONF_UID, diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8837210b6ad..dce4f9f2210 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,7 +54,6 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_MODES_CUSTOM, API_THERMOSTAT_PRESETS, - PERCENTAGE_FAN_MAP, Cause, Inputs, ) @@ -360,17 +359,9 @@ async def async_api_set_percentage(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = "off" - + service = fan.SERVICE_SET_PERCENTAGE percentage = int(directive.payload["percentage"]) - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - elif percentage <= 100: - speed = "high" - data[fan.ATTR_SPEED] = speed + data[fan.ATTR_PERCENTAGE] = percentage await hass.services.async_call( entity.domain, service, data, blocking=False, context=context @@ -388,22 +379,12 @@ async def async_api_adjust_percentage(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = entity.attributes.get(fan.ATTR_SPEED) - current = PERCENTAGE_FAN_MAP.get(speed, 100) + service = fan.SERVICE_SET_PERCENTAGE + current = entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 # set percentage - percentage = max(0, percentage_delta + current) - speed = "off" - - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - elif percentage <= 100: - speed = "high" - - data[fan.ATTR_SPEED] = speed + percentage = min(100, max(0, percentage_delta + current)) + data[fan.ATTR_PERCENTAGE] = percentage await hass.services.async_call( entity.domain, service, data, blocking=False, context=context @@ -854,18 +835,9 @@ async def async_api_set_power_level(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = "off" - + service = fan.SERVICE_SET_PERCENTAGE percentage = int(directive.payload["powerLevel"]) - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - else: - speed = "high" - - data[fan.ATTR_SPEED] = speed + data[fan.ATTR_PERCENTAGE] = percentage await hass.services.async_call( entity.domain, service, data, blocking=False, context=context @@ -883,22 +855,12 @@ async def async_api_adjust_power_level(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = entity.attributes.get(fan.ATTR_SPEED) - current = PERCENTAGE_FAN_MAP.get(speed, 100) + service = fan.SERVICE_SET_PERCENTAGE + current = entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 # set percentage - percentage = max(0, percentage_delta + current) - speed = "off" - - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - else: - speed = "high" - - data[fan.ATTR_SPEED] = speed + percentage = min(100, max(0, percentage_delta + current)) + data[fan.ATTR_PERCENTAGE] = percentage await hass.services.async_call( entity.domain, service, data, blocking=False, context=context diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index d66906810b2..c34dc34f0dd 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -73,10 +73,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): if not should_report and interface.properties_proactively_reported(): should_report = True - if ( - interface.name() == "Alexa.DoorbellEventSource" - and new_state.state == STATE_ON - ): + if interface.name() == "Alexa.DoorbellEventSource": should_doorbell = True break @@ -84,27 +81,22 @@ async def async_enable_proactive_mode(hass, smart_home_config): return if should_doorbell: - should_report = False + if new_state.state == STATE_ON: + await async_send_doorbell_event_message( + hass, smart_home_config, alexa_changed_entity + ) + return - if should_report: - alexa_properties = list(alexa_changed_entity.serialize_properties()) - else: - alexa_properties = None + alexa_properties = list(alexa_changed_entity.serialize_properties()) if not checker.async_is_significant_change( new_state, extra_arg=alexa_properties ): return - if should_report: - await async_send_changereport_message( - hass, smart_home_config, alexa_changed_entity, alexa_properties - ) - - elif should_doorbell: - await async_send_doorbell_event_message( - hass, smart_home_config, alexa_changed_entity - ) + await async_send_changereport_message( + hass, smart_home_config, alexa_changed_entity, alexa_properties + ) return hass.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener @@ -246,7 +238,7 @@ async def async_send_delete_message(hass, config, entity_ids): async def async_send_doorbell_event_message(hass, config, alexa_entity): """Send a DoorbellPress event message for an Alexa entity. - https://developer.amazon.com/docs/smarthome/send-events-to-the-alexa-event-gateway.html + https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html """ token = await config.async_get_access_token() diff --git a/homeassistant/components/almond/translations/ko.json b/homeassistant/components/almond/translations/ko.json index eff796699e3..062ef885c70 100644 --- a/homeassistant/components/almond/translations/ko.json +++ b/homeassistant/components/almond/translations/ko.json @@ -1,9 +1,10 @@ { "config": { "abort": { - "cannot_connect": "Almond \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", - "missing_configuration": "Almond \uc124\uc815 \ubc29\ubc95\uc5d0 \ub300\ud55c \uc124\uba85\uc11c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index fb9560832ca..bdb46abda9a 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -6,6 +6,7 @@ import botocore import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider +from homeassistant.const import ATTR_CREDENTIALS, CONF_PROFILE_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -13,8 +14,6 @@ _LOGGER = logging.getLogger(__name__) CONF_REGION = "region_name" CONF_ACCESS_KEY_ID = "aws_access_key_id" CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" -ATTR_CREDENTIALS = "credentials" DEFAULT_REGION = "us-east-1" SUPPORTED_REGIONS = [ diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json index b08b8919da2..c51c25a2f61 100644 --- a/homeassistant/components/ambiclimate/strings.json +++ b/homeassistant/components/ambiclimate/strings.json @@ -3,7 +3,7 @@ "step": { "auth": { "title": "Authenticate Ambiclimate", - "description": "Please follow this [link]({authorization_url}) and **Allow** access to your Ambiclimate account, then come back and press **Submit** below.\n(Make sure the specified callback url is {cb_url})" + "description": "Please follow this [link]({authorization_url}) and **Allow** access to your Ambiclimate account, then come back and press **Submit** below.\n(Make sure the specified callback URL is {cb_url})" } }, "create_entry": { diff --git a/homeassistant/components/ambiclimate/translations/ca.json b/homeassistant/components/ambiclimate/translations/ca.json index b635d877ffe..8e54a222217 100644 --- a/homeassistant/components/ambiclimate/translations/ca.json +++ b/homeassistant/components/ambiclimate/translations/ca.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "V\u00e9s a l'[enlla\u00e7]({authorization_url}) i **Permet** l'acc\u00e9s al teu compte de Ambiclimate, despr\u00e9s torna i prem **Envia** (a sota).\n(Assegura't que l'enlla\u00e7 de retorn \u00e9s el seg\u00fcent {cb_url})", + "description": "V\u00e9s a l'[enlla\u00e7]({authorization_url}) i **Permet** l'acc\u00e9s al teu compte de Ambiclimate, despr\u00e9s torna i prem **Envia** a sota.\n(Assegura't que l'enlla\u00e7 de retorn \u00e9s el seg\u00fcent {cb_url})", "title": "Autenticaci\u00f3 amb Ambi Climate" } } diff --git a/homeassistant/components/ambiclimate/translations/en.json b/homeassistant/components/ambiclimate/translations/en.json index 01c52875250..8621b0e247c 100644 --- a/homeassistant/components/ambiclimate/translations/en.json +++ b/homeassistant/components/ambiclimate/translations/en.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "Please follow this [link]({authorization_url}) and **Allow** access to your Ambiclimate account, then come back and press **Submit** below.\n(Make sure the specified callback url is {cb_url})", + "description": "Please follow this [link]({authorization_url}) and **Allow** access to your Ambiclimate account, then come back and press **Submit** below.\n(Make sure the specified callback URL is {cb_url})", "title": "Authenticate Ambiclimate" } } diff --git a/homeassistant/components/ambiclimate/translations/et.json b/homeassistant/components/ambiclimate/translations/et.json index f9da8f8f7cd..ff2264c3e0e 100644 --- a/homeassistant/components/ambiclimate/translations/et.json +++ b/homeassistant/components/ambiclimate/translations/et.json @@ -9,7 +9,7 @@ "default": "Ambiclimate autentimine \u00f5nnestus" }, "error": { - "follow_link": "Enne Esita nupu vajutamist j\u00e4rgige linki ja autentige", + "follow_link": "Enne Esita nupu vajutamist j\u00e4rgi linki ja autendi", "no_token": "Ambiclimate ei ole autenditud" }, "step": { diff --git a/homeassistant/components/ambiclimate/translations/ko.json b/homeassistant/components/ambiclimate/translations/ko.json index 2a5e9280aa7..c28affbebb3 100644 --- a/homeassistant/components/ambiclimate/translations/ko.json +++ b/homeassistant/components/ambiclimate/translations/ko.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070 \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + "access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070 \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." }, "create_entry": { - "default": "Ambi Climate \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "follow_link": "\ud655\uc778\uc744 \ud074\ub9ad\ud558\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", @@ -12,7 +14,7 @@ }, "step": { "auth": { - "description": "[\ub9c1\ud06c]({authorization_url}) \ub97c \ud074\ub9ad\ud558\uc5ec Ambi Climate \uacc4\uc815\uc5d0 \ub300\ud574 **\ud5c8\uc6a9**\ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 **\ud655\uc778**\uc744 \ud074\ub9ad\ud574\uc8fc\uc138\uc694. \n(\ucf5c\ubc31 url \uc744 {cb_url} \ub85c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694)", + "description": "[\ub9c1\ud06c]({authorization_url}) \ub97c \ud074\ub9ad\ud558\uc5ec Ambiclimate \uacc4\uc815\uc5d0 \ub300\ud574 **\ud5c8\uc6a9**\ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 **\ud655\uc778**\uc744 \ud074\ub9ad\ud574\uc8fc\uc138\uc694.\n(\ucf5c\ubc31 URL \uc774 {cb_url} \ub85c \uc9c0\uc815\ub418\uc5c8\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694)", "title": "Ambi Climate \uc778\uc99d\ud558\uae30" } } diff --git a/homeassistant/components/ambiclimate/translations/nl.json b/homeassistant/components/ambiclimate/translations/nl.json index 52f8cfc40d3..1d7652a370e 100644 --- a/homeassistant/components/ambiclimate/translations/nl.json +++ b/homeassistant/components/ambiclimate/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "access_token": "Onbekende fout bij het genereren van een toegangstoken.", - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen." }, "create_entry": { "default": "Succesvol geverifieerd met Ambiclimate" diff --git a/homeassistant/components/ambiclimate/translations/no.json b/homeassistant/components/ambiclimate/translations/no.json index c39aa7637f8..6feaabadacc 100644 --- a/homeassistant/components/ambiclimate/translations/no.json +++ b/homeassistant/components/ambiclimate/translations/no.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og **Tillat** tilgang til din Ambiclimate konto, kom deretter tilbake og trykk **Send** nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", + "description": "F\u00f8lg denne [linken]({authorization_url}) og **Tillat** tilgang til Ambiclimate-kontoen din, og kom deretter tilbake og trykk **Send** nedenfor.\n(Kontroller at den angitte url-adressen for tilbakeringing er {cb_url})", "title": "Godkjenn Ambiclimate" } } diff --git a/homeassistant/components/ambiclimate/translations/ru.json b/homeassistant/components/ambiclimate/translations/ru.json index 8c8863c0eec..a1948c45d0f 100644 --- a/homeassistant/components/ambiclimate/translations/ru.json +++ b/homeassistant/components/ambiclimate/translations/ru.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 **\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435** \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Ambi Climate, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**. \n(\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 URL \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 {cb_url})", + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 **\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435** \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Ambi Climate, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**. \n(\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441 \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 {cb_url})", "title": "Ambi Climate" } } diff --git a/homeassistant/components/ambiclimate/translations/zh-Hant.json b/homeassistant/components/ambiclimate/translations/zh-Hant.json index f91c38dd36e..e50accd7327 100644 --- a/homeassistant/components/ambiclimate/translations/zh-Hant.json +++ b/homeassistant/components/ambiclimate/translations/zh-Hant.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "\u8acb\u4f7f\u7528\u6b64[\u9023\u7d50]\uff08{authorization_url}\uff09\u4e26\u9ede\u9078 **\u5141\u8a31** \u4ee5\u5b58\u53d6 Ambiclimate \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684 **\u50b3\u9001**\u3002\n\uff08\u78ba\u5b9a Callback url \u70ba {cb_url}\uff09", + "description": "\u8acb\u4f7f\u7528\u6b64 [\u9023\u7d50]\uff08{authorization_url}\uff09\u4e26\u9ede\u9078**\u5141\u8a31**\u4ee5\u5b58\u53d6 Ambiclimate \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684**\u50b3\u9001**\u3002\n\uff08\u78ba\u5b9a\u6307\u5b9a Callback URL \u70ba {cb_url}\uff09", "title": "\u8a8d\u8b49 Ambiclimate" } } diff --git a/homeassistant/components/ambient_station/translations/ko.json b/homeassistant/components/ambient_station/translations/ko.json index d4e227656c2..6fc8f4b17fc 100644 --- a/homeassistant/components/ambient_station/translations/ko.json +++ b/homeassistant/components/ambient_station/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc571 \ud0a4\ub294 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "invalid_key": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index f383f982abc..e40a9332c38 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -377,7 +377,7 @@ class APIDomainServicesView(HomeAssistantView): with AsyncTrackStates(hass) as changed_states: try: await hass.services.async_call( - domain, service, data, True, self.context(request) + domain, service, data, blocking=True, context=self.context(request) ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index eca5e91ddeb..b41a107d126 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -180,20 +180,23 @@ class AppleTVManager: This is a callback function from pyatv.interface.DeviceListener. """ - _LOGGER.warning('Connection lost to Apple TV "%s"', self.atv.name) - if self.atv: - self.atv.close() - self.atv = None + _LOGGER.warning( + 'Connection lost to Apple TV "%s"', self.config_entry.data.get(CONF_NAME) + ) self._connection_was_lost = True - self._dispatch_send(SIGNAL_DISCONNECTED) - self._start_connect_loop() + self._handle_disconnect() def connection_closed(self): """Device connection was (intentionally) closed. This is a callback function from pyatv.interface.DeviceListener. """ + self._handle_disconnect() + + def _handle_disconnect(self): + """Handle that the device disconnected and restart connect loop.""" if self.atv: + self.atv.listener = None self.atv.close() self.atv = None self._dispatch_send(SIGNAL_DISCONNECTED) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9c2f25b6d53..ef0a0cfe59e 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -101,10 +101,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(info[CONF_IDENTIFIER]) self.target_device = info[CONF_IDENTIFIER] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"name": info[CONF_NAME]} - - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["identifier"] = self.unique_id return await self.async_step_reconfigure() @@ -170,7 +167,6 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(identifier) self._abort_if_unique_id_configured() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["identifier"] = self.unique_id self.context["title_placeholders"] = {"name": name} self.target_device = identifier diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 81bb79dc50b..a855fc6b53e 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -1,22 +1,35 @@ """Support for Apple TV media player.""" import logging -from pyatv.const import DeviceState, FeatureName, FeatureState, MediaType +from pyatv.const import ( + DeviceState, + FeatureName, + FeatureState, + MediaType, + RepeatState, + ShuffleState, +) from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, + 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_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( CONF_NAME, @@ -46,6 +59,9 @@ SUPPORT_APPLE_TV = ( | SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + | SUPPORT_VOLUME_STEP + | SUPPORT_REPEAT_SET + | SUPPORT_SHUFFLE_SET ) @@ -110,17 +126,15 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def app_id(self): """ID of the current running app.""" - if self.atv: - if self.atv.features.in_state(FeatureState.Available, FeatureName.App): - return self.atv.metadata.app.identifier + if self._is_feature_available(FeatureName.App): + return self.atv.metadata.app.identifier return None @property def app_name(self): """Name of the current running app.""" - if self.atv: - if self.atv.features.in_state(FeatureState.Available, FeatureName.App): - return self.atv.metadata.app.name + if self._is_feature_available(FeatureName.App): + return self.atv.metadata.app.name return None @property @@ -198,6 +212,23 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): return self._playing.album return None + @property + def repeat(self): + """Return current repeat mode.""" + if self._is_feature_available(FeatureName.Repeat): + return { + RepeatState.Track: REPEAT_MODE_ONE, + RepeatState.All: REPEAT_MODE_ALL, + }.get(self._playing.repeat, REPEAT_MODE_OFF) + return None + + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + if self._is_feature_available(FeatureName.Shuffle): + return self._playing.shuffle != ShuffleState.Off + return None + @property def supported_features(self): """Flag media player features that are supported.""" @@ -221,39 +252,61 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_media_play_pause(self): """Pause media on media player.""" if self._playing: - state = self.state - if state == STATE_PAUSED: - await self.atv.remote_control.play() - elif state == STATE_PLAYING: - await self.atv.remote_control.pause() + await self.atv.remote_control.play_pause() return None async def async_media_play(self): """Play media.""" - if self._playing: + if self.atv: await self.atv.remote_control.play() async def async_media_stop(self): """Stop the media player.""" - if self._playing: + if self.atv: await self.atv.remote_control.stop() async def async_media_pause(self): """Pause the media player.""" - if self._playing: + if self.atv: await self.atv.remote_control.pause() async def async_media_next_track(self): """Send next track command.""" - if self._playing: + if self.atv: await self.atv.remote_control.next() async def async_media_previous_track(self): """Send previous track command.""" - if self._playing: + if self.atv: await self.atv.remote_control.previous() async def async_media_seek(self, position): """Send seek command.""" - if self._playing: + if self.atv: await self.atv.remote_control.set_position(position) + + async def async_volume_up(self): + """Turn volume up for media player.""" + if self.atv: + await self.atv.remote_control.volume_up() + + async def async_volume_down(self): + """Turn volume down for media player.""" + if self.atv: + await self.atv.remote_control.volume_down() + + async def async_set_repeat(self, repeat): + """Set repeat mode.""" + if self.atv: + mode = { + REPEAT_MODE_ONE: RepeatState.Track, + REPEAT_MODE_ALL: RepeatState.All, + }.get(repeat, RepeatState.Off) + await self.atv.remote_control.set_repeat(mode) + + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + if self.atv: + await self.atv.remote_control.set_shuffle( + ShuffleState.Songs if shuffle else ShuffleState.Off + ) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index a76c4c6a208..3d88bddcbc9 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -1,8 +1,14 @@ """Remote control support for Apple TV.""" +import asyncio import logging -from homeassistant.components.remote import RemoteEntity +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + RemoteEntity, +) from homeassistant.const import CONF_NAME from . import AppleTVEntity @@ -43,12 +49,19 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): async def async_send_command(self, command, **kwargs): """Send a command to one device.""" + num_repeats = kwargs[ATTR_NUM_REPEATS] + delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + if not self.is_on: _LOGGER.error("Unable to send commands, not connected to %s", self._name) return - for single_command in command: - if not hasattr(self.atv.remote_control, single_command): - continue + for _ in range(num_repeats): + for single_command in command: + attr_value = getattr(self.atv.remote_control, single_command, None) + if not attr_value: + raise ValueError("Command not found. Exiting sequence") - await getattr(self.atv.remote_control, single_command)() + _LOGGER.info("Sending command %s", single_command) + await attr_value() + await asyncio.sleep(delay) diff --git a/homeassistant/components/apple_tv/translations/ko.json b/homeassistant/components/apple_tv/translations/ko.json new file mode 100644 index 00000000000..c7e664b0638 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured_device": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "pair_with_pin": { + "data": { + "pin": "PIN \ucf54\ub4dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/nl.json b/homeassistant/components/apple_tv/translations/nl.json index a11488ebca9..d809ac749b7 100644 --- a/homeassistant/components/apple_tv/translations/nl.json +++ b/homeassistant/components/apple_tv/translations/nl.json @@ -1,9 +1,41 @@ { "config": { "abort": { + "already_configured_device": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", "backoff": "Het apparaat accepteert op dit moment geen koppelingsverzoeken (u heeft mogelijk te vaak een ongeldige pincode ingevoerd), probeer het later opnieuw.", "device_did_not_pair": "Er is geen poging gedaan om het koppelingsproces te voltooien vanaf het apparaat.", - "invalid_config": "De configuratie voor dit apparaat is onvolledig. Probeer het opnieuw toe te voegen." + "invalid_config": "De configuratie voor dit apparaat is onvolledig. Probeer het opnieuw toe te voegen.", + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "unknown": "Onverwachte fout" + }, + "error": { + "already_configured": "Apparaat is al geconfigureerd", + "invalid_auth": "Ongeldige authenticatie", + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "unknown": "Onverwachte fout" + }, + "flow_title": "Apple TV: {name}", + "step": { + "confirm": { + "title": "Bevestig het toevoegen van Apple TV" + }, + "pair_no_pin": { + "title": "Koppelen" + }, + "pair_with_pin": { + "data": { + "pin": "PIN-code" + }, + "title": "Koppelen" + }, + "user": { + "data": { + "device_input": "Apparaat" + }, + "title": "Stel een nieuwe Apple TV in" + } } - } + }, + "title": "Apple TV" } \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/ru.json b/homeassistant/components/apple_tv/translations/ru.json index e3f5804cebe..4ad9b9f52c7 100644 --- a/homeassistant/components/apple_tv/translations/ru.json +++ b/homeassistant/components/apple_tv/translations/ru.json @@ -11,7 +11,7 @@ }, "error": { "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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "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.", "no_usable_service": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u043f\u043e\u0441\u043e\u0431 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0415\u0441\u043b\u0438 \u0412\u044b \u0443\u0436\u0435 \u0432\u0438\u0434\u0435\u043b\u0438 \u044d\u0442\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 \u0435\u0433\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/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index d270af9295b..31735a0a037 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -71,7 +71,7 @@ class ArcamFmjFlowHandler(config_entries.ConfigFlow): async def async_step_confirm(self, user_input=None): """Handle user-confirmation of discovered node.""" - context = self.context # pylint: disable=no-member + context = self.context placeholders = { "host": context[CONF_HOST], } @@ -94,7 +94,7 @@ class ArcamFmjFlowHandler(config_entries.ConfigFlow): await self._async_set_unique_id_and_update(host, port, uuid) - context = self.context # pylint: disable=no-member + context = self.context context[CONF_HOST] = host context[CONF_PORT] = DEFAULT_PORT return await self.async_step_confirm() diff --git a/homeassistant/components/arcam_fmj/translations/ko.json b/homeassistant/components/arcam_fmj/translations/ko.json index 62b5a54928e..532e5ef4c5f 100644 --- a/homeassistant/components/arcam_fmj/translations/ko.json +++ b/homeassistant/components/arcam_fmj/translations/ko.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Arcam FMJ: {host}", "step": { diff --git a/homeassistant/components/arcam_fmj/translations/nl.json b/homeassistant/components/arcam_fmj/translations/nl.json index 5607b426cc9..03465d5c53d 100644 --- a/homeassistant/components/arcam_fmj/translations/nl.json +++ b/homeassistant/components/arcam_fmj/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", "cannot_connect": "Kan geen verbinding maken" }, "error": { diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 1829d00a353..28e8fe76684 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -1,120 +1,174 @@ """Support for ASUSWRT devices.""" -import logging +import asyncio -from aioasuswrt.asuswrt import AsusWrt import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MODE, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, + CONF_SENSORS, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import HomeAssistantType -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + DATA_ASUSWRT, + DEFAULT_DNSMASQ, + DEFAULT_INTERFACE, + DEFAULT_SSH_PORT, + DOMAIN, + MODE_AP, + MODE_ROUTER, + PROTOCOL_SSH, + PROTOCOL_TELNET, + SENSOR_TYPES, +) +from .router import AsusWrtRouter + +PLATFORMS = ["device_tracker", "sensor"] -CONF_DNSMASQ = "dnsmasq" -CONF_INTERFACE = "interface" CONF_PUB_KEY = "pub_key" -CONF_REQUIRE_IP = "require_ip" -CONF_SENSORS = "sensors" -CONF_SSH_KEY = "ssh_key" - -DOMAIN = "asuswrt" -DATA_ASUSWRT = DOMAIN - -DEFAULT_SSH_PORT = 22 -DEFAULT_INTERFACE = "eth0" -DEFAULT_DNSMASQ = "/var/lib/misc" - -FIRST_RETRY_TIME = 60 -MAX_RETRY_TIME = 900 - SECRET_GROUP = "Password or SSH Key" -SENSOR_TYPES = ["devices", "upload_speed", "download_speed", "download", "upload"] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PROTOCOL, default="ssh"): vol.In(["ssh", "telnet"]), - vol.Optional(CONF_MODE, default="router"): vol.In(["router", "ap"]), - vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, - vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, - vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, - vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, - vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile, - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, - vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.string, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PROTOCOL, default=PROTOCOL_SSH): vol.In( + [PROTOCOL_SSH, PROTOCOL_TELNET] + ), + vol.Optional(CONF_MODE, default=MODE_ROUTER): vol.In( + [MODE_ROUTER, MODE_AP] + ), + vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile, + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) -async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): - """Set up the asuswrt component.""" - - conf = config[DOMAIN] - - api = AsusWrt( - conf[CONF_HOST], - conf[CONF_PORT], - conf[CONF_PROTOCOL] == "telnet", - conf[CONF_USERNAME], - conf.get(CONF_PASSWORD, ""), - conf.get("ssh_key", conf.get("pub_key", "")), - conf[CONF_MODE], - conf[CONF_REQUIRE_IP], - interface=conf[CONF_INTERFACE], - dnsmasq=conf[CONF_DNSMASQ], - ) - - try: - await api.connection.async_connect() - except OSError as ex: - _LOGGER.warning( - "Error [%s] connecting %s to %s. Will retry in %s seconds...", - str(ex), - DOMAIN, - conf[CONF_HOST], - retry_delay, - ) - - async def retry_setup(now): - """Retry setup if a error happens on asuswrt API.""" - await async_setup( - hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME) - ) - - async_call_later(hass, retry_delay, retry_setup) - +async def async_setup(hass, config): + """Set up the AsusWrt integration.""" + conf = config.get(DOMAIN) + if conf is None: return True - if not api.is_connected: - _LOGGER.error("Error connecting %s to %s", DOMAIN, conf[CONF_HOST]) - return False + # save the options from config yaml + options = {} + mode = conf.get(CONF_MODE, MODE_ROUTER) + for name, value in conf.items(): + if name in ([CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]): + if name == CONF_REQUIRE_IP and mode != MODE_AP: + continue + options[name] = value + hass.data[DOMAIN] = {"yaml_options": options} - hass.data[DATA_ASUSWRT] = api + # check if already configured + domains_list = hass.config_entries.async_domains() + if DOMAIN in domains_list: + return True + + # remove not required config keys + pub_key = conf.pop(CONF_PUB_KEY, "") + if pub_key: + conf[CONF_SSH_KEY] = pub_key + + conf.pop(CONF_REQUIRE_IP, True) + conf.pop(CONF_SENSORS, {}) + conf.pop(CONF_INTERFACE, "") + conf.pop(CONF_DNSMASQ, "") hass.async_create_task( - async_load_platform( - hass, "sensor", DOMAIN, config[DOMAIN].get(CONF_SENSORS), config + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) ) - hass.async_create_task( - async_load_platform(hass, "device_tracker", DOMAIN, {}, config) - ) return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up AsusWrt platform.""" + + # import options from yaml if empty + yaml_options = hass.data.get(DOMAIN, {}).pop("yaml_options", {}) + if not entry.options and yaml_options: + hass.config_entries.async_update_entry(entry, options=yaml_options) + + router = AsusWrtRouter(hass, entry) + await router.setup() + + router.async_on_close(entry.add_update_listener(update_listener)) + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + async def async_close_connection(event): + """Close AsusWrt connection on HA Stop.""" + await router.close() + + stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_close_connection + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_ASUSWRT: router, + "stop_listener": stop_listener, + } + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][entry.entry_id]["stop_listener"]() + router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + await router.close() + + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistantType, entry: ConfigEntry): + """Update when config_entry options update.""" + router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + + if router.update_options(entry.options): + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py new file mode 100644 index 00000000000..303b3cc3822 --- /dev/null +++ b/homeassistant/components/asuswrt/config_flow.py @@ -0,0 +1,238 @@ +"""Config flow to configure the AsusWrt integration.""" +import logging +import os +import socket + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +# pylint:disable=unused-import +from .const import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + CONF_TRACK_UNKNOWN, + DEFAULT_DNSMASQ, + DEFAULT_INTERFACE, + DEFAULT_SSH_PORT, + DEFAULT_TRACK_UNKNOWN, + DOMAIN, + MODE_AP, + MODE_ROUTER, + PROTOCOL_SSH, + PROTOCOL_TELNET, +) +from .router import get_api + +RESULT_CONN_ERROR = "cannot_connect" +RESULT_UNKNOWN = "unknown" +RESULT_SUCCESS = "success" + +_LOGGER = logging.getLogger(__name__) + + +def _is_file(value) -> bool: + """Validate that the value is an existing file.""" + file_in = os.path.expanduser(str(value)) + + if not os.path.isfile(file_in): + return False + if not os.access(file_in, os.R_OK): + return False + return True + + +def _get_ip(host): + """Get the ip address from the host name.""" + try: + return socket.gethostbyname(host) + except socket.gaierror: + return None + + +class AsusWrtFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize AsusWrt config flow.""" + self._host = None + + @callback + def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_SSH_KEY): str, + vol.Required(CONF_PROTOCOL, default=PROTOCOL_SSH): vol.In( + {PROTOCOL_SSH: "SSH", PROTOCOL_TELNET: "Telnet"} + ), + vol.Required(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In( + {MODE_ROUTER: "Router", MODE_AP: "Access Point"} + ), + } + ), + errors=errors or {}, + ) + + async def _async_check_connection(self, user_input): + """Attempt to connect the AsusWrt router.""" + + api = get_api(user_input) + try: + await api.connection.async_connect() + + except OSError: + _LOGGER.error("Error connecting to the AsusWrt router at %s", self._host) + return RESULT_CONN_ERROR + + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with AsusWrt router at %s", self._host + ) + return RESULT_UNKNOWN + + if not api.is_connected: + _LOGGER.error("Error connecting to the AsusWrt router at %s", self._host) + return RESULT_CONN_ERROR + + conf_protocol = user_input[CONF_PROTOCOL] + if conf_protocol == PROTOCOL_TELNET: + await api.connection.disconnect() + return RESULT_SUCCESS + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self._show_setup_form(user_input) + + errors = {} + self._host = user_input[CONF_HOST] + pwd = user_input.get(CONF_PASSWORD) + ssh = user_input.get(CONF_SSH_KEY) + + if not (pwd or ssh): + errors["base"] = "pwd_or_ssh" + elif ssh: + if pwd: + errors["base"] = "pwd_and_ssh" + else: + isfile = await self.hass.async_add_executor_job(_is_file, ssh) + if not isfile: + errors["base"] = "ssh_not_file" + + if not errors: + ip_address = await self.hass.async_add_executor_job(_get_ip, self._host) + if not ip_address: + errors["base"] = "invalid_host" + + if not errors: + result = await self._async_check_connection(user_input) + if result != RESULT_SUCCESS: + errors["base"] = result + + if errors: + return self._show_setup_form(user_input, errors) + + return self.async_create_entry( + title=self._host, + data=user_input, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for AsusWrt.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), + vol.Optional( + CONF_TRACK_UNKNOWN, + default=self.config_entry.options.get( + CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN + ), + ): bool, + vol.Required( + CONF_INTERFACE, + default=self.config_entry.options.get( + CONF_INTERFACE, DEFAULT_INTERFACE + ), + ): str, + vol.Required( + CONF_DNSMASQ, + default=self.config_entry.options.get( + CONF_DNSMASQ, DEFAULT_DNSMASQ + ), + ): str, + } + ) + + conf_mode = self.config_entry.data[CONF_MODE] + if conf_mode == MODE_AP: + data_schema = data_schema.extend( + { + vol.Optional( + CONF_REQUIRE_IP, + default=self.config_entry.options.get(CONF_REQUIRE_IP, True), + ): bool, + } + ) + + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py new file mode 100644 index 00000000000..40752e81a08 --- /dev/null +++ b/homeassistant/components/asuswrt/const.py @@ -0,0 +1,24 @@ +"""AsusWrt component constants.""" +DOMAIN = "asuswrt" + +CONF_DNSMASQ = "dnsmasq" +CONF_INTERFACE = "interface" +CONF_REQUIRE_IP = "require_ip" +CONF_SSH_KEY = "ssh_key" +CONF_TRACK_UNKNOWN = "track_unknown" + +DATA_ASUSWRT = DOMAIN + +DEFAULT_DNSMASQ = "/var/lib/misc" +DEFAULT_INTERFACE = "eth0" +DEFAULT_SSH_PORT = 22 +DEFAULT_TRACK_UNKNOWN = False + +MODE_AP = "ap" +MODE_ROUTER = "router" + +PROTOCOL_SSH = "ssh" +PROTOCOL_TELNET = "telnet" + +# Sensor +SENSOR_TYPES = ["devices", "upload_speed", "download_speed", "download", "upload"] diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index a3545183d2e..385b25755b0 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -1,64 +1,140 @@ """Support for ASUSWRT routers.""" -import logging +from typing import Dict -from homeassistant.components.device_tracker import DeviceScanner +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType -from . import DATA_ASUSWRT +from .const import DATA_ASUSWRT, DOMAIN +from .router import AsusWrtRouter -_LOGGER = logging.getLogger(__name__) +DEFAULT_DEVICE_NAME = "Unknown device" -async def async_get_scanner(hass, config): - """Validate the configuration and return an ASUS-WRT scanner.""" - scanner = AsusWrtDeviceScanner(hass.data[DATA_ASUSWRT]) - await scanner.async_connect() - return scanner if scanner.success_init else None +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up device tracker for AsusWrt component.""" + router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + tracked = set() + + @callback + def update_router(): + """Update the values of the router.""" + add_entities(router, async_add_entities, tracked) + + router.async_on_close( + async_dispatcher_connect(hass, router.signal_device_new, update_router) + ) + + update_router() -class AsusWrtDeviceScanner(DeviceScanner): - """This class queries a router running ASUSWRT firmware.""" +@callback +def add_entities(router, async_add_entities, tracked): + """Add new tracker entities from the router.""" + new_tracked = [] - # Eighth attribute needed for mode (AP mode vs router mode) - def __init__(self, api): - """Initialize the scanner.""" - self.last_results = {} - self.success_init = False - self.connection = api - self._connect_error = False + for mac, device in router.devices.items(): + if mac in tracked: + continue - async def async_connect(self): - """Initialize connection to the router.""" - # Test the router is accessible. - data = await self.connection.async_get_connected_devices() - self.success_init = data is not None + new_tracked.append(AsusWrtDevice(router, device)) + tracked.add(mac) - async def async_scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - await self.async_update_info() - return list(self.last_results) + if new_tracked: + async_add_entities(new_tracked) - async def async_get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - if device not in self.last_results: - return None - return self.last_results[device].name - async def async_update_info(self): - """Ensure the information from the ASUSWRT router is up to date. +class AsusWrtDevice(ScannerEntity): + """Representation of a AsusWrt device.""" - Return boolean if scanning successful. - """ - _LOGGER.debug("Checking Devices") + def __init__(self, router: AsusWrtRouter, device) -> None: + """Initialize a AsusWrt device.""" + self._router = router + self._mac = device.mac + self._name = device.name or DEFAULT_DEVICE_NAME + self._active = False + self._icon = None + self._attrs = {} - try: - self.last_results = await self.connection.async_get_connected_devices() - if self._connect_error: - self._connect_error = False - _LOGGER.info("Reconnected to ASUS router for device update") + @callback + def async_update_state(self) -> None: + """Update the AsusWrt device.""" + device = self._router.devices[self._mac] + self._active = device.is_connected - except OSError as err: - if not self._connect_error: - self._connect_error = True - _LOGGER.error( - "Error connecting to ASUS router for device update: %s", err - ) + self._attrs = { + "mac": device.mac, + "ip_address": device.ip_address, + } + if device.last_activity: + self._attrs["last_time_reachable"] = device.last_activity.isoformat( + timespec="seconds" + ) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._mac + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return self._active + + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def device_state_attributes(self) -> Dict[str, any]: + """Return the attributes.""" + return self._attrs + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "AsusWRT Tracked device", + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @callback + def async_on_demand_update(self): + """Update state.""" + self.async_update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_update_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_device_update, + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 9afb7849f8c..744a05b9728 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -1,6 +1,7 @@ { "domain": "asuswrt", "name": "ASUSWRT", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", "requirements": ["aioasuswrt==1.3.1"], "codeowners": ["@kennedyshead"] diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py new file mode 100644 index 00000000000..11545919b43 --- /dev/null +++ b/homeassistant/components/asuswrt/router.py @@ -0,0 +1,274 @@ +"""Represent the AsusWrt router.""" +from datetime import datetime, timedelta +import logging +from typing import Any, Dict, Optional + +from aioasuswrt.asuswrt import AsusWrt + +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, + DOMAIN as TRACKER_DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +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 ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + CONF_TRACK_UNKNOWN, + DEFAULT_DNSMASQ, + DEFAULT_INTERFACE, + DEFAULT_TRACK_UNKNOWN, + DOMAIN, + PROTOCOL_TELNET, +) + +CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class AsusWrtDevInfo: + """Representation of a AsusWrt device info.""" + + def __init__(self, mac, name=None): + """Initialize a AsusWrt device info.""" + self._mac = mac + self._name = name + self._ip_address = None + self._last_activity = None + self._connected = False + + def update(self, dev_info=None, consider_home=0): + """Update AsusWrt device info.""" + utc_point_in_time = dt_util.utcnow() + if dev_info: + if not self._name: + self._name = dev_info.name or self._mac.replace(":", "_") + self._ip_address = dev_info.ip + self._last_activity = utc_point_in_time + self._connected = True + + elif self._connected: + self._connected = ( + utc_point_in_time - self._last_activity + ).total_seconds() < consider_home + self._ip_address = None + + @property + def is_connected(self): + """Return connected status.""" + return self._connected + + @property + def mac(self): + """Return device mac address.""" + return self._mac + + @property + def name(self): + """Return device name.""" + return self._name + + @property + def ip_address(self): + """Return device ip address.""" + return self._ip_address + + @property + def last_activity(self): + """Return device last activity.""" + return self._last_activity + + +class AsusWrtRouter: + """Representation of a AsusWrt router.""" + + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Initialize a AsusWrt router.""" + self.hass = hass + self._entry = entry + + self._api: AsusWrt = None + self._protocol = entry.data[CONF_PROTOCOL] + self._host = entry.data[CONF_HOST] + + self._devices: Dict[str, Any] = {} + self._connect_error = False + + self._on_close = [] + + self._options = { + CONF_DNSMASQ: DEFAULT_DNSMASQ, + CONF_INTERFACE: DEFAULT_INTERFACE, + CONF_REQUIRE_IP: True, + } + self._options.update(entry.options) + + async def setup(self) -> None: + """Set up a AsusWrt router.""" + self._api = get_api(self._entry.data, self._options) + + try: + await self._api.connection.async_connect() + except OSError as exp: + raise ConfigEntryNotReady from exp + + if not self._api.is_connected: + raise ConfigEntryNotReady + + # 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 + ) + ) + for entry in track_entries: + if entry.domain == TRACKER_DOMAIN: + self._devices[entry.unique_id] = AsusWrtDevInfo( + entry.unique_id, entry.original_name + ) + + # Update devices + await self.update_devices() + + self.async_on_close( + async_track_time_interval(self.hass, self.update_all, SCAN_INTERVAL) + ) + + async def update_all(self, now: Optional[datetime] = None) -> None: + """Update all AsusWrt platforms.""" + await self.update_devices() + + async def update_devices(self) -> None: + """Update AsusWrt devices tracker.""" + new_device = False + _LOGGER.debug("Checking devices for ASUS router %s", self._host) + try: + wrt_devices = await self._api.async_get_connected_devices() + except OSError as exc: + if not self._connect_error: + self._connect_error = True + _LOGGER.error( + "Error connecting to ASUS router %s for device update: %s", + self._host, + exc, + ) + return + + if self._connect_error: + self._connect_error = False + _LOGGER.info("Reconnected to ASUS router %s", self._host) + + consider_home = self._options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ) + track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN) + + for device_mac in self._devices: + dev_info = wrt_devices.get(device_mac) + self._devices[device_mac].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 + device = AsusWrtDevInfo(device_mac) + device.update(dev_info) + self._devices[device_mac] = device + + async_dispatcher_send(self.hass, self.signal_device_update) + if new_device: + async_dispatcher_send(self.hass, self.signal_device_new) + + async def close(self) -> None: + """Close the connection.""" + if self._api is not None: + if self._protocol == PROTOCOL_TELNET: + await self._api.connection.disconnect() + self._api = None + + for func in self._on_close: + func() + self._on_close.clear() + + @callback + def async_on_close(self, func: CALLBACK_TYPE) -> None: + """Add a function to call when router is closed.""" + self._on_close.append(func) + + def update_options(self, new_options: Dict) -> bool: + """Update router options.""" + req_reload = False + for name, new_opt in new_options.items(): + if name in (CONF_REQ_RELOAD): + old_opt = self._options.get(name) + if not old_opt or old_opt != new_opt: + req_reload = True + break + + self._options.update(new_options) + return req_reload + + @property + def signal_device_new(self) -> str: + """Event specific per AsusWrt entry to signal new device.""" + return f"{DOMAIN}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per AsusWrt entry to signal updates in devices.""" + return f"{DOMAIN}-device-update" + + @property + def host(self) -> str: + """Return router hostname.""" + return self._host + + @property + def devices(self) -> Dict[str, Any]: + """Return devices.""" + return self._devices + + @property + def api(self) -> AsusWrt: + """Return router API.""" + return self._api + + +def get_api(conf: Dict, options: Optional[Dict] = None) -> AsusWrt: + """Get the AsusWrt API.""" + opt = options or {} + + return AsusWrt( + conf[CONF_HOST], + conf[CONF_PORT], + conf[CONF_PROTOCOL] == PROTOCOL_TELNET, + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + conf.get(CONF_SSH_KEY, ""), + conf[CONF_MODE], + opt.get(CONF_REQUIRE_IP, True), + interface=opt.get(CONF_INTERFACE, DEFAULT_INTERFACE), + dnsmasq=opt.get(CONF_DNSMASQ, DEFAULT_DNSMASQ), + ) diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index aa13bee81d0..2a39d339f06 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -6,13 +6,15 @@ from typing import Any, Dict, List, Optional from aioasuswrt.asuswrt import AsusWrt -from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from . import DATA_ASUSWRT +from .const import DATA_ASUSWRT, DOMAIN, SENSOR_TYPES UPLOAD_ICON = "mdi:upload-network" DOWNLOAD_ICON = "mdi:download-network" @@ -35,6 +37,8 @@ class _SensorTypes(enum.Enum): return DATA_GIGABYTES if self in (_SensorTypes.UPLOAD_SPEED, _SensorTypes.DOWNLOAD_SPEED): return DATA_RATE_MEGABITS_PER_SECOND + if self == _SensorTypes.DEVICES: + return "devices" return None @property @@ -72,15 +76,26 @@ class _SensorTypes(enum.Enum): return self in (_SensorTypes.UPLOAD, _SensorTypes.DOWNLOAD) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the asuswrt sensors.""" - if discovery_info is None: - return +class _SensorInfo: + """Class handling sensor information.""" - api: AsusWrt = hass.data[DATA_ASUSWRT] + def __init__(self, sensor_type: _SensorTypes): + """Initialize the handler class.""" + self.type = sensor_type + self.enabled = False + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the asuswrt sensors.""" + + router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + api: AsusWrt = router.api + device_name = entry.data.get(CONF_NAME, "AsusWRT") # Let's discover the valid sensor types. - sensors = [_SensorTypes(x) for x in discovery_info] + sensors = [_SensorInfo(_SensorTypes(x)) for x in SENSOR_TYPES] data_handler = AsuswrtDataHandler(sensors, api) coordinator = DataUpdateCoordinator( @@ -93,34 +108,50 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) await coordinator.async_refresh() - async_add_entities([AsuswrtSensor(coordinator, x) for x in sensors]) + async_add_entities( + [AsuswrtSensor(coordinator, data_handler, device_name, x.type) for x in sensors] + ) class AsuswrtDataHandler: """Class handling the API updates.""" - def __init__(self, sensors: List[_SensorTypes], api: AsusWrt): + def __init__(self, sensors: List[_SensorInfo], api: AsusWrt): """Initialize the handler class.""" self._api = api self._sensors = sensors self._connected = True + def enable_sensor(self, sensor_type: _SensorTypes): + """Enable a specific sensor type.""" + for index, sensor in enumerate(self._sensors): + if sensor.type == sensor_type: + self._sensors[index].enabled = True + return + + def disable_sensor(self, sensor_type: _SensorTypes): + """Disable a specific sensor type.""" + for index, sensor in enumerate(self._sensors): + if sensor.type == sensor_type: + self._sensors[index].enabled = False + return + async def update_data(self) -> Dict[_SensorTypes, Any]: """Fetch the relevant data from the router.""" ret_dict: Dict[_SensorTypes, Any] = {} try: - if _SensorTypes.DEVICES in self._sensors: + if _SensorTypes.DEVICES in [x.type for x in self._sensors if x.enabled]: # Let's check the nr of devices. devices = await self._api.async_get_connected_devices() ret_dict[_SensorTypes.DEVICES] = len(devices) - if any(x.is_speed for x in self._sensors): + if any(x.type.is_speed for x in self._sensors if x.enabled): # Let's check the upload and download speed speed = await self._api.async_get_current_transfer_rates() ret_dict[_SensorTypes.DOWNLOAD_SPEED] = round(speed[0] / 125000, 2) ret_dict[_SensorTypes.UPLOAD_SPEED] = round(speed[1] / 125000, 2) - if any(x.is_size for x in self._sensors): + if any(x.type.is_size for x in self._sensors if x.enabled): rates = await self._api.async_get_bytes_total() ret_dict[_SensorTypes.DOWNLOAD] = round(rates[0] / 1000000000, 1) ret_dict[_SensorTypes.UPLOAD] = round(rates[1] / 1000000000, 1) @@ -142,9 +173,17 @@ class AsuswrtDataHandler: class AsuswrtSensor(CoordinatorEntity): """The asuswrt specific sensor class.""" - def __init__(self, coordinator: DataUpdateCoordinator, sensor_type: _SensorTypes): + def __init__( + self, + coordinator: DataUpdateCoordinator, + data_handler: AsuswrtDataHandler, + device_name: str, + sensor_type: _SensorTypes, + ): """Initialize the sensor class.""" super().__init__(coordinator) + self._handler = data_handler + self._device_name = device_name self._type = sensor_type @property @@ -164,5 +203,34 @@ class AsuswrtSensor(CoordinatorEntity): @property def unit_of_measurement(self) -> Optional[str]: - """Return the unit of measurement of this entity, if any.""" + """Return the unit.""" return self._type.unit_of_measurement + + @property + def unique_id(self) -> str: + """Return the unique_id of the sensor.""" + return f"{DOMAIN} {self._type.sensor_name}" + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "identifiers": {(DOMAIN, "AsusWRT")}, + "name": self._device_name, + "model": "Asus Router", + "manufacturer": "Asus", + } + + @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_added_to_hass(self) -> None: + """When entity is added to hass.""" + self._handler.enable_sensor(self._type) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self): + """Call when entity is removed from hass.""" + self._handler.disable_sensor(self._type) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json new file mode 100644 index 00000000000..079ee35bf95 --- /dev/null +++ b/homeassistant/components/asuswrt/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "title": "AsusWRT", + "description": "Set required parameter to connect to your router", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "ssh_key": "Path to your SSH key file (instead of password)", + "protocol": "Communication protocol to use", + "port": "[%key:common::config_flow::data::port%]", + "mode": "[%key:common::config_flow::data::mode%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "pwd_and_ssh": "Only provide password or SSH key file", + "pwd_or_ssh": "Please provide password or SSH key file", + "ssh_not_file": "SSH key file not found", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "title": "AsusWRT Options", + "data": { + "consider_home": "Seconds to wait before considering a device away", + "track_unknown": "Track unknown / unamed devices", + "interface": "The interface that you want statistics from (e.g. eth0,eth1 etc)", + "dnsmasq": "The location in the router of the dnsmasq.leases files", + "require_ip": "Devices must have IP (for access point mode)" + } + } + } + } +} diff --git a/homeassistant/components/asuswrt/translations/ca.json b/homeassistant/components/asuswrt/translations/ca.json new file mode 100644 index 00000000000..2b15199a092 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/ca.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_host": "Nom de l'amfitri\u00f3 o l'adre\u00e7a IP inv\u00e0lids", + "pwd_and_ssh": "Proporciona, nom\u00e9s, la contrasenya o el fitxer de claus SSH", + "pwd_or_ssh": "Proporciona la contrasenya o el fitxer de claus SSH", + "ssh_not_file": "No s'ha trobat el fitxer de claus SSH", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "mode": "Mode", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "protocol": "Protocol de comunicacions a utilitzar", + "ssh_key": "Ruta al fitxer de claus SSH (en lloc de la contrasenya)", + "username": "Nom d'usuari" + }, + "description": "Introdueix el par\u00e0metre necessari per connectar-te al router", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segons d'espera abans de considerar un dispositiu a fora", + "dnsmasq": "La ubicaci\u00f3 dins el router dels fitxers dnsmasq.leases", + "interface": "La interf\u00edcie de la qual obtenir les estad\u00edstiques (per exemple, eth0, eth1, etc.)", + "require_ip": "Els dispositius han de tenir una IP (per al mode de punt d'acc\u00e9s)", + "track_unknown": "Segueix dispositius desconeguts/sense nom" + }, + "title": "Opcions d'AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/cs.json b/homeassistant/components/asuswrt/translations/cs.json new file mode 100644 index 00000000000..d9766e9a6d0 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/cs.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_host": "Neplatn\u00fd hostitel nebo IP adresa", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "mode": "Re\u017eim", + "name": "Jm\u00e9no", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/de.json b/homeassistant/components/asuswrt/translations/de.json new file mode 100644 index 00000000000..433bf17b814 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Modus", + "name": "Name", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/en.json b/homeassistant/components/asuswrt/translations/en.json new file mode 100644 index 00000000000..5ac87e277f4 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/en.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_host": "Invalid hostname or IP address", + "pwd_and_ssh": "Only provide password or SSH key file", + "pwd_or_ssh": "Please provide password or SSH key file", + "ssh_not_file": "SSH key file not found", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Mode", + "name": "Name", + "password": "Password", + "port": "Port", + "protocol": "Communication protocol to use", + "ssh_key": "Path to your SSH key file (instead of password)", + "username": "Username" + }, + "description": "Set required parameter to connect to your router", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to wait before considering a device away", + "dnsmasq": "The location in the router of the dnsmasq.leases files", + "interface": "The interface that you want statistics from (e.g. eth0,eth1 etc)", + "require_ip": "Devices must have IP (for access point mode)", + "track_unknown": "Track unknown / unamed devices" + }, + "title": "AsusWRT Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/es.json b/homeassistant/components/asuswrt/translations/es.json new file mode 100644 index 00000000000..c5792babf00 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/es.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_host": "Nombre de host o direcci\u00f3n IP no v\u00e1lidos", + "pwd_and_ssh": "S\u00f3lo proporcionar la contrase\u00f1a o el archivo de clave SSH", + "pwd_or_ssh": "Por favor, proporcione la contrase\u00f1a o el archivo de clave SSH", + "ssh_not_file": "Archivo de clave SSH no encontrado", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Modo", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "protocol": "Protocolo de comunicaci\u00f3n a utilizar", + "ssh_key": "Ruta de acceso a su archivo de clave SSH (en lugar de la contrase\u00f1a)", + "username": "Nombre de usuario" + }, + "description": "Establezca los par\u00e1metros necesarios para conectarse a su router", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos de espera antes de considerar un dispositivo ausente", + "dnsmasq": "La ubicaci\u00f3n en el router de los archivos dnsmasq.leases", + "interface": "La interfaz de la que desea obtener estad\u00edsticas (por ejemplo, eth0, eth1, etc.)", + "require_ip": "Los dispositivos deben tener IP (para el modo de punto de acceso)", + "track_unknown": "Seguimiento de dispositivos desconocidos / sin nombre" + }, + "title": "Opciones de AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/et.json b/homeassistant/components/asuswrt/translations/et.json new file mode 100644 index 00000000000..8cc14b7353b --- /dev/null +++ b/homeassistant/components/asuswrt/translations/et.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_host": "Sobimatu hostinimi v\u00f5i IP-aadress", + "pwd_and_ssh": "Sisesta ainult parooli v\u00f5i SSH v\u00f5tmefail", + "pwd_or_ssh": "Sisesta parool v\u00f5i SSH v\u00f5tmefail", + "ssh_not_file": "SSH v\u00f5tmefaili ei leitud", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Re\u017eiim", + "name": "Nimi", + "password": "Salas\u00f5na", + "port": "Port", + "protocol": "Kasutatav sideprotokoll", + "ssh_key": "Rada SSH v\u00f5tmefailini (parooli asemel)", + "username": "Kasutajanimi" + }, + "description": "M\u00e4\u00e4ra ruuteriga \u00fchenduse loomiseks vajalik parameeter", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Mitu sekundit oodata, enne kui lugeda seade eemal olevaks", + "dnsmasq": "Dnsmasq.leases failide asukoht ruuteris", + "interface": "Liides kust soovite statistikat (n\u00e4iteks eth0, eth1 jne.)", + "require_ip": "Seadmetel peab olema IP (p\u00e4\u00e4supunkti re\u017eiimi jaoks)", + "track_unknown": "J\u00e4lgi tundmatuid / nimetamata seadmeid" + }, + "title": "AsusWRT valikud" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/fr.json b/homeassistant/components/asuswrt/translations/fr.json new file mode 100644 index 00000000000..0d53f3f24cf --- /dev/null +++ b/homeassistant/components/asuswrt/translations/fr.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", + "pwd_and_ssh": "Fournissez uniquement le mot de passe ou le fichier de cl\u00e9 SSH", + "pwd_or_ssh": "Veuillez fournir un mot de passe ou un fichier de cl\u00e9 SSH", + "ssh_not_file": "Fichier cl\u00e9 SSH non trouv\u00e9", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "mode": "Mode", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "protocol": "Protocole de communication \u00e0 utiliser", + "ssh_key": "Chemin d'acc\u00e8s \u00e0 votre fichier de cl\u00e9s SSH (au lieu du mot de passe)", + "username": "Nom d'utilisateur" + }, + "description": "D\u00e9finissez les param\u00e8tres n\u00e9cessaires pour vous connecter \u00e0 votre routeur", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Quelques secondes d'attente avant d'envisager l'abandon d'un appareil", + "dnsmasq": "L\u2019emplacement dans le routeur des fichiers dnsmasq.leases", + "interface": "L'interface \u00e0 partir de laquelle vous souhaitez obtenir des statistiques (e.g. eth0,eth1 etc)", + "require_ip": "Les appareils doivent avoir une IP (pour le mode point d'acc\u00e8s)", + "track_unknown": "Traquer les appareils inconnus / non identifi\u00e9s" + }, + "title": "Options AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/it.json b/homeassistant/components/asuswrt/translations/it.json new file mode 100644 index 00000000000..d266cabbed4 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/it.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_host": "Nome host o indirizzo IP non valido", + "pwd_and_ssh": "Fornire solo la password o il file della chiave SSH", + "pwd_or_ssh": "Si prega di fornire la password o il file della chiave SSH", + "ssh_not_file": "File chiave SSH non trovato", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Modalit\u00e0", + "name": "Nome", + "password": "Password", + "port": "Porta", + "protocol": "Protocollo di comunicazione da utilizzare", + "ssh_key": "Percorso del file della chiave SSH (invece della password)", + "username": "Nome utente" + }, + "description": "Imposta il parametro richiesto per collegarti al tuo router", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Secondi di attesa prima di considerare un dispositivo lontano", + "dnsmasq": "La posizione nel router dei file dnsmasq.leases", + "interface": "L'interfaccia da cui si desidera ottenere statistiche (ad esempio eth0, eth1, ecc.)", + "require_ip": "I dispositivi devono avere un IP (per la modalit\u00e0 punto di accesso)", + "track_unknown": "Tieni traccia dei dispositivi sconosciuti / non denominati" + }, + "title": "Opzioni AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/ko.json b/homeassistant/components/asuswrt/translations/ko.json new file mode 100644 index 00000000000..de3de06e6b1 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "mode": "\ubaa8\ub4dc", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/nl.json b/homeassistant/components/asuswrt/translations/nl.json new file mode 100644 index 00000000000..9d1e76aaf2b --- /dev/null +++ b/homeassistant/components/asuswrt/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_host": "Ongeldige hostnaam of IP-adres", + "ssh_not_file": "SSH-sleutelbestand niet gevonden", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "mode": "Mode", + "name": "Naam", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "track_unknown": "Volg onbekende / naamloze apparaten" + }, + "title": "AsusWRT-opties" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/no.json b/homeassistant/components/asuswrt/translations/no.json new file mode 100644 index 00000000000..42c9798d495 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/no.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_host": "Ugyldig vertsnavn eller IP-adresse", + "pwd_and_ssh": "Oppgi bare passord eller SSH-n\u00f8kkelfil", + "pwd_or_ssh": "Oppgi passord eller SSH-n\u00f8kkelfil", + "ssh_not_file": "Finner ikke SSH-n\u00f8kkelfilen", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "mode": "Modus", + "name": "Navn", + "password": "Passord", + "port": "Port", + "protocol": "Kommunikasjonsprotokoll som skal brukes", + "ssh_key": "Bane til SSH-n\u00f8kkelfilen (i stedet for passord)", + "username": "Brukernavn" + }, + "description": "Sett \u00f8nsket parameter for \u00e5 koble til ruteren", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Sekunder \u00e5 vente f\u00f8r du vurderer en enhet borte", + "dnsmasq": "Plasseringen i ruteren til dnsmasq.leases-filene", + "interface": "Grensesnittet du vil ha statistikk fra (f.eks. Eth0, eth1 osv.)", + "require_ip": "Enheter m\u00e5 ha IP (for tilgangspunktmodus)", + "track_unknown": "Spor ukjente / ikke-navngitte enheter" + }, + "title": "AsusWRT-alternativer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/pl.json b/homeassistant/components/asuswrt/translations/pl.json new file mode 100644 index 00000000000..9fd5d00b1c4 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/pl.json @@ -0,0 +1,45 @@ +{ + "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_host": "Nieprawid\u0142owa nazwa hosta lub adres IP", + "pwd_and_ssh": "Podaj tylko has\u0142o lub plik z kluczem SSH", + "pwd_or_ssh": "Podaj has\u0142o lub plik z kluczem SSH", + "ssh_not_file": "Nie znaleziono pliku z kluczem SSH", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "mode": "Tryb", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "protocol": "Wybierz protok\u00f3\u0142 komunikacyjny", + "ssh_key": "\u015acie\u017cka do pliku z kluczem SSH (zamiast has\u0142a)", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Ustaw wymagany parametr, aby po\u0142\u0105czy\u0107 si\u0119 z routerem", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Czas w sekundach, zanim urz\u0105dzenie utrzyma stan \"poza domem\"", + "dnsmasq": "Lokalizacja w routerze plik\u00f3w dnsmasq.leases", + "interface": "Interfejs, z kt\u00f3rego chcesz uzyska\u0107 statystyki (np. eth0, eth1 itp.)", + "require_ip": "Urz\u0105dzenia musz\u0105 mie\u0107 adres IP (w trybie punktu dost\u0119pu)", + "track_unknown": "\u015aled\u017a nieznane / nienazwane urz\u0105dzenia" + }, + "title": "Opcje AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/ru.json b/homeassistant/components/asuswrt/translations/ru.json new file mode 100644 index 00000000000..236f7642c12 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/ru.json @@ -0,0 +1,45 @@ +{ + "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_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441.", + "pwd_and_ssh": "\u041d\u0443\u0436\u043d\u043e \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0430\u0440\u043e\u043b\u044c \u0438\u043b\u0438 \u0442\u043e\u043b\u044c\u043a\u043e \u0444\u0430\u0439\u043b \u043a\u043b\u044e\u0447\u0430 SSH.", + "pwd_or_ssh": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0438\u043b\u0438 \u0444\u0430\u0439\u043b \u043a\u043b\u044e\u0447\u0430 SSH.", + "ssh_not_file": "\u0424\u0430\u0439\u043b \u043a\u043b\u044e\u0447\u0430 SSH \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d.", + "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", + "mode": "\u0420\u0435\u0436\u0438\u043c", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "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)", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "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.", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\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 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "dnsmasq": "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 \u0444\u0430\u0439\u043b\u043e\u0432 dnsmasq.leases", + "interface": "\u0418\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441, \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0443 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, eth0, eth1 \u0438 \u0442. \u0434.)", + "require_ip": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c (\u0434\u043b\u044f \u0440\u0435\u0436\u0438\u043c\u0430 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", + "track_unknown": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0435/\u0431\u0435\u0437\u044b\u043c\u044f\u043d\u043d\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/zh-Hant.json b/homeassistant/components/asuswrt/translations/zh-Hant.json new file mode 100644 index 00000000000..8caddacd23e --- /dev/null +++ b/homeassistant/components/asuswrt/translations/zh-Hant.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740", + "pwd_and_ssh": "\u50c5\u63d0\u4f9b\u5bc6\u78bc\u6216 SSH \u5bc6\u9470\u6a94\u6848", + "pwd_or_ssh": "\u8acb\u8f38\u5165\u5bc6\u78bc\u6216 SSH \u5bc6\u9470\u6a94\u6848", + "ssh_not_file": "\u627e\u4e0d\u5230 SSH \u5bc6\u9470\u6a94\u6848", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "mode": "\u6a21\u5f0f", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "protocol": "\u4f7f\u7528\u901a\u8a0a\u606f\u5354\u5b9a", + "ssh_key": "SSH \u5bc6\u9470\u6a94\u6848\u8def\u5f91\uff08\u975e\u5bc6\u78bc\uff09", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a\u6240\u9700\u53c3\u6578\u4ee5\u9023\u7dda\u81f3\u8def\u7531\u5668", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u8996\u70ba\u96e2\u958b\u7684\u7b49\u5019\u79d2\u6578", + "dnsmasq": "dnsmasq.leases \u6a94\u6848\u65bc\u8def\u7531\u5668\u4e2d\u6240\u5728\u4f4d\u7f6e", + "interface": "\u6240\u8981\u9032\u884c\u7d71\u8a08\u7684\u4ecb\u9762\u53e3\uff08\u4f8b\u5982 eth0\u3001eth1 \u7b49\uff09", + "require_ip": "\u88dd\u7f6e\u5fc5\u9808\u5177\u6709 IP\uff08\u7528\u65bc AP \u6a21\u5f0f\uff09", + "track_unknown": "\u8ffd\u8e64\u672a\u77e5 / \u672a\u547d\u540d\u88dd\u7f6e" + }, + "title": "AsusWRT \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/et.json b/homeassistant/components/atag/translations/et.json index 2a4094806ed..fd0651a219c 100644 --- a/homeassistant/components/atag/translations/et.json +++ b/homeassistant/components/atag/translations/et.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00dchendamine nurjus", - "unauthorized": "Sidumine on keelatud, kontrollige seadme tuvastamistaotlust" + "unauthorized": "Sidumine on keelatud, kontrolli seadme tuvastamistaotlust" }, "step": { "user": { diff --git a/homeassistant/components/atag/translations/ko.json b/homeassistant/components/atag/translations/ko.json index c09b4f7b249..9b0c1ea1b36 100644 --- a/homeassistant/components/atag/translations/ko.json +++ b/homeassistant/components/atag/translations/ko.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 HomeAssistant \uc5d0 \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unauthorized": "\ud398\uc5b4\ub9c1\uc774 \uac70\ubd80\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc778\uc99d \uc694\uccad \uae30\uae30\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694" }, "step": { "user": { "data": { - "email": "\uc774\uba54\uc77c (\uc120\ud0dd \uc0ac\ud56d)", + "email": "\uc774\uba54\uc77c", "host": "\ud638\uc2a4\ud2b8", "port": "\ud3ec\ud2b8" }, diff --git a/homeassistant/components/august/translations/it.json b/homeassistant/components/august/translations/it.json index 08332c29d7e..adc9017a275 100644 --- a/homeassistant/components/august/translations/it.json +++ b/homeassistant/components/august/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/august/translations/ko.json b/homeassistant/components/august/translations/ko.json index 52f939c45a0..e7aed3d4c2c 100644 --- a/homeassistant/components/august/translations/ko.json +++ b/homeassistant/components/august/translations/ko.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/august/translations/nl.json b/homeassistant/components/august/translations/nl.json index 1697f634d9a..e48d27801cc 100644 --- a/homeassistant/components/august/translations/nl.json +++ b/homeassistant/components/august/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Account al geconfigureerd" + "already_configured": "Account al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "cannot_connect": "Verbinding mislukt, probeer het opnieuw", diff --git a/homeassistant/components/august/translations/ru.json b/homeassistant/components/august/translations/ru.json index 9ea0b531bf8..97dba8fc758 100644 --- a/homeassistant/components/august/translations/ru.json +++ b/homeassistant/components/august/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/august/translations/sv.json b/homeassistant/components/august/translations/sv.json index df72f5daaf3..a3a0b891bc6 100644 --- a/homeassistant/components/august/translations/sv.json +++ b/homeassistant/components/august/translations/sv.json @@ -13,10 +13,16 @@ "data": { "login_method": "Inloggningsmetod", "password": "L\u00f6senord", + "timeout": "Timeout (sekunder)", "username": "Anv\u00e4ndarnamn" - } + }, + "description": "Om inloggningsmetoden \u00e4r \"e-post\" \u00e4r anv\u00e4ndarnamnet e-postadressen. Om inloggningsmetoden \u00e4r \"telefon\" \u00e4r anv\u00e4ndarnamnet telefonnumret i formatet \"+ NNNNNNNN\".", + "title": "St\u00e4ll in ett August-konto" }, "validation": { + "data": { + "code": "Verifieringskod" + }, "title": "Tv\u00e5faktorsautentisering" } } diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 260a3bd735d..a187288e2e4 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -4,16 +4,26 @@ import asyncio from datetime import timedelta import logging +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 ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( + ATTR_ENTRY_TYPE, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTRIBUTION, AURORA_API, CONF_THRESHOLD, COORDINATOR, @@ -24,7 +34,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor"] +PLATFORMS = ["binary_sensor", "sensor"] async def async_setup(hass: HomeAssistant, config: dict): @@ -126,5 +136,54 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): try: return await self.api.get_forecast_data(self.longitude, self.latitude) - except ConnectionError as error: + except ClientError as error: raise UpdateFailed(f"Error updating from NOAA: {error}") from error + + +class AuroraEntity(CoordinatorEntity): + """Implementation of the base Aurora Entity.""" + + def __init__( + self, + coordinator: AuroraDataUpdateCoordinator, + name: str, + icon: str, + ): + """Initialize the Aurora Entity.""" + + super().__init__(coordinator=coordinator) + + self._name = name + self._unique_id = f"{self.coordinator.latitude}_{self.coordinator.longitude}" + self._icon = icon + + @property + def unique_id(self): + """Define the unique id based on the latitude and longitude.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {"attribution": ATTRIBUTION} + + @property + def icon(self): + """Return the icon for the sensor.""" + return self._icon + + @property + def device_info(self): + """Define the device based on name.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)}, + ATTR_NAME: self.coordinator.name, + ATTR_MANUFACTURER: "NOAA", + ATTR_MODEL: "Aurora Visibility Sensor", + ATTR_ENTRY_TYPE: "service", + } diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 82be366ce6d..a6d5a1817b2 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -1,75 +1,24 @@ -"""Support for aurora forecast data sensor.""" -import logging - +"""Support for Aurora Forecast binary sensor.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_NAME -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AuroraDataUpdateCoordinator -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTRIBUTION, - COORDINATOR, - DOMAIN, -) - -_LOGGER = logging.getLogger(__name__) +from . import AuroraEntity +from .const import COORDINATOR, DOMAIN async def async_setup_entry(hass, entry, async_add_entries): """Set up the binary_sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - name = coordinator.name + name = f"{coordinator.name} Aurora Visibility Alert" - entity = AuroraSensor(coordinator, name) + entity = AuroraSensor(coordinator=coordinator, name=name, icon="mdi:hazard-lights") async_add_entries([entity]) -class AuroraSensor(CoordinatorEntity, BinarySensorEntity): +class AuroraSensor(AuroraEntity, BinarySensorEntity): """Implementation of an aurora sensor.""" - def __init__(self, coordinator: AuroraDataUpdateCoordinator, name): - """Define the binary sensor for the Aurora integration.""" - super().__init__(coordinator=coordinator) - - self._name = name - self.coordinator = coordinator - self._unique_id = f"{self.coordinator.latitude}_{self.coordinator.longitude}" - - @property - def unique_id(self): - """Define the unique id based on the latitude and longitude.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return true if aurora is visible.""" return self.coordinator.data > self.coordinator.threshold - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return {"attribution": ATTRIBUTION} - - @property - def icon(self): - """Return the icon for the sensor.""" - return "mdi:hazard-lights" - - @property - def device_info(self): - """Define the device based on name.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)}, - ATTR_NAME: self.coordinator.name, - ATTR_MANUFACTURER: "NOAA", - ATTR_MODEL: "Aurora Visibility Sensor", - } diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 37885cc87cf..24161c059c1 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,6 +1,7 @@ """Config flow for SpaceX Launches and Starman.""" import logging +from aiohttp import ClientError from auroranoaa import AuroraForecast import voluptuous as vol @@ -9,7 +10,12 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_THRESHOLD, DEFAULT_NAME, DEFAULT_THRESHOLD, DOMAIN +from .const import ( # pylint: disable=unused-import + CONF_THRESHOLD, + DEFAULT_NAME, + DEFAULT_THRESHOLD, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -40,14 +46,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await api.get_forecast_data(longitude, latitude) - except ConnectionError: + except ClientError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( - f"{DOMAIN}_{user_input[CONF_LONGITUDE]}_{user_input[CONF_LATITUDE]}" + f"{user_input[CONF_LONGITUDE]}_{user_input[CONF_LATITUDE]}" ) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index f4451de863d..cd6f54a3d0c 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -6,6 +6,7 @@ AURORA_API = "aurora_api" ATTR_IDENTIFIERS = "identifiers" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" +ATTR_ENTRY_TYPE = "entry_type" DEFAULT_POLLING_INTERVAL = 5 CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py new file mode 100644 index 00000000000..731c6d08afd --- /dev/null +++ b/homeassistant/components/aurora/sensor.py @@ -0,0 +1,32 @@ +"""Support for Aurora Forecast sensor.""" +from homeassistant.const import PERCENTAGE + +from . import AuroraEntity +from .const import COORDINATOR, DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entries): + """Set up the sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + entity = AuroraSensor( + coordinator=coordinator, + name=f"{coordinator.name} Aurora Visibility %", + icon="mdi:gauge", + ) + + async_add_entries([entity]) + + +class AuroraSensor(AuroraEntity): + """Implementation of an aurora sensor.""" + + @property + def state(self): + """Return % chance the aurora is visible.""" + return self.coordinator.data + + @property + def unit_of_measurement(self): + """Return the unit of measure.""" + return PERCENTAGE diff --git a/homeassistant/components/aurora/translations/ko.json b/homeassistant/components/aurora/translations/ko.json new file mode 100644 index 00000000000..ea10c059f03 --- /dev/null +++ b/homeassistant/components/aurora/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/nl.json b/homeassistant/components/aurora/translations/nl.json new file mode 100644 index 00000000000..fe7b4809f13 --- /dev/null +++ b/homeassistant/components/aurora/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "Drempel (%)" + } + } + } + }, + "title": "NOAA Aurora Sensor" +} \ No newline at end of file diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 43451632f38..4ddf82cc022 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -115,11 +115,13 @@ Result will be a long-lived access token: """ from datetime import timedelta +from typing import Union import uuid 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, @@ -180,9 +182,11 @@ RESULT_TYPE_USER = "user" @bind_hass -def create_auth_code(hass, client_id: str, user: User) -> str: +def create_auth_code( + hass, client_id: str, credential_or_user: Union[Credentials, User] +) -> str: """Create an authorization code to fetch tokens.""" - return hass.data[DOMAIN](client_id, user) + return hass.data[DOMAIN](client_id, credential_or_user) async def async_setup(hass, config): @@ -228,9 +232,9 @@ class TokenView(HomeAssistantView): requires_auth = False cors_allowed = True - def __init__(self, retrieve_user): + def __init__(self, retrieve_auth): """Initialize the token view.""" - self._retrieve_user = retrieve_user + self._retrieve_auth = retrieve_auth @log_invalid_auth async def post(self, request): @@ -293,16 +297,15 @@ class TokenView(HomeAssistantView): status_code=HTTP_BAD_REQUEST, ) - user = self._retrieve_user(client_id, RESULT_TYPE_USER, code) + credential = self._retrieve_auth(client_id, RESULT_TYPE_CREDENTIALS, code) - if user is None or not isinstance(user, User): + if credential is None or not isinstance(credential, Credentials): return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, status_code=HTTP_BAD_REQUEST, ) - # refresh user - user = await hass.auth.async_get_user(user.id) + user = await hass.auth.async_get_or_create_user(credential) if not user.is_active: return self.json( @@ -310,8 +313,18 @@ class TokenView(HomeAssistantView): status_code=HTTP_FORBIDDEN, ) - refresh_token = await hass.auth.async_create_refresh_token(user, client_id) - access_token = hass.auth.async_create_access_token(refresh_token, remote_addr) + refresh_token = await hass.auth.async_create_refresh_token( + user, client_id, credential=credential + ) + try: + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr + ) + except InvalidAuthError as exc: + return self.json( + {"error": "access_denied", "error_description": str(exc)}, + status_code=HTTP_FORBIDDEN, + ) return self.json( { @@ -346,7 +359,15 @@ class TokenView(HomeAssistantView): if refresh_token.client_id != client_id: return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) - access_token = hass.auth.async_create_access_token(refresh_token, remote_addr) + try: + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr + ) + except InvalidAuthError as exc: + return self.json( + {"error": "access_denied", "error_description": str(exc)}, + status_code=HTTP_FORBIDDEN, + ) return self.json( { @@ -482,7 +503,12 @@ async def websocket_create_long_lived_access_token( access_token_expiration=timedelta(days=msg["lifespan"]), ) - access_token = hass.auth.async_create_access_token(refresh_token) + try: + access_token = hass.auth.async_create_access_token(refresh_token) + except InvalidAuthError as exc: + return websocket_api.error_message( + msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc) + ) connection.send_message(websocket_api.result_message(msg["id"], access_token)) diff --git a/homeassistant/components/auth/translations/et.json b/homeassistant/components/auth/translations/et.json index 03fc42f38e2..9b22951e7fa 100644 --- a/homeassistant/components/auth/translations/et.json +++ b/homeassistant/components/auth/translations/et.json @@ -10,7 +10,7 @@ "step": { "init": { "description": "Vali \u00fcks teavitusteenustest:", - "title": "Seadistage Notify poolt edastatud \u00fchekordne parool" + "title": "Seadista Notify poolt edastatud \u00fchekordne parool" }, "setup": { "description": "\u00dchekordne parool on saadetud **notify. {notify_service}**. Palun sisesta see allpool:", @@ -26,7 +26,7 @@ "step": { "init": { "description": "Kahefaktorilise autentimise aktiveerimiseks ajap\u00f5histe \u00fchekordsete paroolide abil skanni QR-kood oma autentimisrakendusega. Kui seda pole, soovitame kas [Google Authenticator] (https://support.google.com/accounts/answer/1066447) v\u00f5i [Authy] (https://authy.com/).\n\n {qr_code}\n\n P\u00e4rast koodi skannimist sisesta seadistuse kinnitamiseks rakenduse kuuekohaline kood. Kui on probleeme QR-koodi skannimisega, tehke koodiga **' {code}' ** k\u00e4sitsi seadistamine.", - "title": "Seadistage TOTP-ga kaheastmeline autentimine" + "title": "Seadista TOTP-ga kaheastmeline autentimine" } }, "title": "" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 201eeb5c456..7f006d929b1 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, CONF_ALIAS, + CONF_CONDITION, CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_ID, @@ -31,7 +32,12 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + ConditionError, + ConditionErrorContainer, + ConditionErrorIndex, + HomeAssistantError, +) from homeassistant.helpers import condition, extract_domain_configs, template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity @@ -57,9 +63,9 @@ from .config import PLATFORM_SCHEMA # noqa from .config import async_validate_config_item from .const import ( CONF_ACTION, - CONF_CONDITION, CONF_INITIAL_STATE, CONF_TRIGGER, + CONF_TRIGGER_VARIABLES, DEFAULT_INITIAL_STATE, DOMAIN, LOGGER, @@ -221,6 +227,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): action_script, initial_state, variables, + trigger_variables, ): """Initialize an automation entity.""" self._id = automation_id @@ -236,6 +243,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._referenced_devices: Optional[Set[str]] = None self._logger = LOGGER self._variables: ScriptVariables = variables + self._trigger_variables: ScriptVariables = trigger_variables @property def name(self): @@ -471,6 +479,16 @@ class AutomationEntity(ToggleEntity, RestoreEntity): def log_cb(level, msg, **kwargs): self._logger.log(level, "%s %s", msg, self._name, **kwargs) + variables = None + if self._trigger_variables: + try: + variables = self._trigger_variables.async_render( + cast(HomeAssistant, self.hass), None, limited=True + ) + except template.TemplateError as err: + self._logger.error("Error rendering trigger variables: %s", err) + return None + return await async_initialize_triggers( cast(HomeAssistant, self.hass), self._trigger_config, @@ -479,6 +497,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._name, log_cb, home_assistant_start, + variables, ) @property @@ -549,13 +568,25 @@ async def _async_process_config( ) if CONF_CONDITION in config_block: - cond_func = await _async_process_if(hass, config, config_block) + cond_func = await _async_process_if(hass, name, config, config_block) if cond_func is None: continue else: cond_func = None + # Add trigger variables to variables + variables = None + if CONF_TRIGGER_VARIABLES in config_block: + variables = ScriptVariables( + dict(config_block[CONF_TRIGGER_VARIABLES].as_dict()) + ) + if CONF_VARIABLES in config_block: + if variables: + variables.variables.update(config_block[CONF_VARIABLES].as_dict()) + else: + variables = config_block[CONF_VARIABLES] + entity = AutomationEntity( automation_id, name, @@ -563,7 +594,8 @@ async def _async_process_config( cond_func, action_script, initial_state, - config_block.get(CONF_VARIABLES), + variables, + config_block.get(CONF_TRIGGER_VARIABLES), ) entities.append(entity) @@ -574,7 +606,7 @@ async def _async_process_config( return blueprints_used -async def _async_process_if(hass, config, p_config): +async def _async_process_if(hass, name, config, p_config): """Process if checks.""" if_configs = p_config[CONF_CONDITION] @@ -588,7 +620,27 @@ async def _async_process_if(hass, config, p_config): def if_action(variables=None): """AND all conditions.""" - return all(check(hass, variables) for check in checks) + errors = [] + for index, check in enumerate(checks): + try: + if not check(hass, variables): + return False + except ConditionError as ex: + errors.append( + ConditionErrorIndex( + "condition", index=index, total=len(checks), error=ex + ) + ) + + if errors: + LOGGER.warning( + "Error evaluating condition in '%s':\n%s", + name, + ConditionErrorContainer("condition", errors=errors), + ) + return False + + return True if_action.config = if_configs diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 9c26f3552aa..32ad92cb86e 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -8,7 +8,7 @@ from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) from homeassistant.config import async_log_exception, config_without_domain -from homeassistant.const import CONF_ALIAS, CONF_ID, CONF_VARIABLES +from homeassistant.const import CONF_ALIAS, CONF_CONDITION, CONF_ID, CONF_VARIABLES from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, config_validation as cv, script from homeassistant.helpers.condition import async_validate_condition_config @@ -17,11 +17,11 @@ from homeassistant.loader import IntegrationNotFound from .const import ( CONF_ACTION, - CONF_CONDITION, CONF_DESCRIPTION, CONF_HIDE_ENTITY, CONF_INITIAL_STATE, CONF_TRIGGER, + CONF_TRIGGER_VARIABLES, DOMAIN, ) from .helpers import async_get_blueprints @@ -44,6 +44,7 @@ PLATFORM_SCHEMA = vol.All( vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(CONF_TRIGGER_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, }, script.SCRIPT_MODE_SINGLE, diff --git a/homeassistant/components/automation/const.py b/homeassistant/components/automation/const.py index c8db3aa01e5..829f78590e0 100644 --- a/homeassistant/components/automation/const.py +++ b/homeassistant/components/automation/const.py @@ -1,9 +1,9 @@ """Constants for the automation integration.""" import logging -CONF_CONDITION = "condition" CONF_ACTION = "action" CONF_TRIGGER = "trigger" +CONF_TRIGGER_VARIABLES = "trigger_variables" DOMAIN = "automation" CONF_DESCRIPTION = "description" diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 2f5b0a231e4..5d399fb253e 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -1,37 +1,40 @@ # Describes the format for available automation services turn_on: + name: Turn on description: Enable an automation. - fields: - entity_id: - description: Name of the automation to turn on. - example: "automation.notify_home" + target: turn_off: + name: Turn off description: Disable an automation. + target: fields: - entity_id: - description: Name of the automation to turn off. - example: "automation.notify_home" stop_actions: - description: Stop currently running actions (defaults to true). - example: false + name: Stop actions + description: Stop currently running actions. + default: true + example: true + selector: + boolean: toggle: - description: Toggle an automation. - fields: - entity_id: - description: Name of the automation to toggle on/off. - example: "automation.notify_home" + name: Toggle + description: Toggle (enable / disable) an automation. + target: trigger: - description: Trigger the action of an automation. + name: Trigger + description: Trigger the actions of an automation. + target: fields: - entity_id: - description: Name of the automation to trigger. - example: "automation.notify_home" skip_condition: - description: Whether or not the condition will be skipped (defaults to true). + name: Skip conditions + description: Whether or not the conditions will be skipped. + default: true example: true + selector: + boolean: reload: + name: Reload description: Reload the automation configuration. diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index b262fdec572..44490b8401f 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -8,6 +8,7 @@ from python_awair.devices import AwairDevice from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -33,7 +34,6 @@ API_VOC = "volatile_organic_compounds" ATTRIBUTION = "Awair air quality sensor" -ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" ATTR_UNIQUE_ID = "unique_id" diff --git a/homeassistant/components/awair/translations/it.json b/homeassistant/components/awair/translations/it.json index 085796f9263..cad2b8555a8 100644 --- a/homeassistant/components/awair/translations/it.json +++ b/homeassistant/components/awair/translations/it.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "no_devices_found": "Nessun dispositivo trovato sulla rete", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_access_token": "Token di accesso non valido", diff --git a/homeassistant/components/awair/translations/ko.json b/homeassistant/components/awair/translations/ko.json index 977532de45d..22677f8ab45 100644 --- a/homeassistant/components/awair/translations/ko.json +++ b/homeassistant/components/awair/translations/ko.json @@ -1,11 +1,13 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "reauth_successful": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc131\uacf5\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "unknown": "\uc54c \uc218 \uc5c6\ub294 Awair API \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + "invalid_access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "reauth": { diff --git a/homeassistant/components/awair/translations/nl.json b/homeassistant/components/awair/translations/nl.json index 08a30a52250..5d20aed2fdb 100644 --- a/homeassistant/components/awair/translations/nl.json +++ b/homeassistant/components/awair/translations/nl.json @@ -2,14 +2,26 @@ "config": { "abort": { "already_configured": "Account is al geconfigureerd", - "no_devices_found": "Geen apparaten op het netwerk gevonden" + "no_devices_found": "Geen apparaten op het netwerk gevonden", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { + "invalid_access_token": "Ongeldig toegangstoken", "unknown": "Onverwachte fout" }, "step": { "reauth": { + "data": { + "access_token": "Toegangstoken", + "email": "E-mail" + }, "description": "Voer uw Awair-ontwikkelaarstoegangstoken opnieuw in." + }, + "user": { + "data": { + "access_token": "Toegangstoken", + "email": "E-mail" + } } } } diff --git a/homeassistant/components/awair/translations/zh-Hant.json b/homeassistant/components/awair/translations/zh-Hant.json index 11fe9ff88b3..0bd7749c65f 100644 --- a/homeassistant/components/awair/translations/zh-Hant.json +++ b/homeassistant/components/awair/translations/zh-Hant.json @@ -6,23 +6,23 @@ "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { - "invalid_access_token": "\u5b58\u53d6\u5bc6\u9470\u7121\u6548", + "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { "reauth": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470", + "access_token": "\u5b58\u53d6\u6b0a\u6756", "email": "\u96fb\u5b50\u90f5\u4ef6" }, - "description": "\u8acb\u91cd\u65b0\u8f38\u5165 Awair \u958b\u767c\u8005\u5b58\u53d6\u5bc6\u9470\u3002" + "description": "\u8acb\u91cd\u65b0\u8f38\u5165 Awair \u958b\u767c\u8005\u5b58\u53d6\u6b0a\u6756\u3002" }, "user": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470", + "access_token": "\u5b58\u53d6\u6b0a\u6756", "email": "\u96fb\u5b50\u90f5\u4ef6" }, - "description": "\u5fc5\u9808\u5148\u8a3b\u518a Awair \u958b\u767c\u8005\u5b58\u53d6\u5bc6\u9470\uff1ahttps://developer.getawair.com/onboard/login" + "description": "\u5fc5\u9808\u5148\u8a3b\u518a Awair \u958b\u767c\u8005\u5b58\u53d6\u6b0a\u6756\uff1ahttps://developer.getawair.com/onboard/login" } } } diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py index 600874b0d25..da8c27d7445 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -7,11 +7,15 @@ import aiobotocore import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ATTR_CREDENTIALS, CONF_NAME, CONF_PROFILE_NAME +from homeassistant.const import ( + ATTR_CREDENTIALS, + CONF_NAME, + CONF_PROFILE_NAME, + CONF_SERVICE, +) from homeassistant.helpers import config_validation as cv, discovery # Loading the config flow file will register the flow -from . import config_flow # noqa: F401 from .const import ( CONF_ACCESS_KEY_ID, CONF_CONTEXT, @@ -20,7 +24,6 @@ from .const import ( CONF_NOTIFY, CONF_REGION, CONF_SECRET_ACCESS_KEY, - CONF_SERVICE, CONF_VALIDATE, DATA_CONFIG, DATA_HASS_CONFIG, @@ -152,7 +155,6 @@ async def async_setup_entry(hass, entry): async def _validate_aws_credentials(hass, credential): """Validate AWS credential config.""" - aws_config = credential.copy() del aws_config[CONF_NAME] del aws_config[CONF_VALIDATE] diff --git a/homeassistant/components/aws/const.py b/homeassistant/components/aws/const.py index 499f4413596..8be6afec7ff 100644 --- a/homeassistant/components/aws/const.py +++ b/homeassistant/components/aws/const.py @@ -10,8 +10,6 @@ CONF_CONTEXT = "context" CONF_CREDENTIAL_NAME = "credential_name" CONF_CREDENTIALS = "credentials" CONF_NOTIFY = "notify" -CONF_PROFILE_NAME = "profile_name" CONF_REGION = "region_name" CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_SERVICE = "service" CONF_VALIDATE = "validate" diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index 13fa189a318..f487bc7aab3 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -12,24 +12,21 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, BaseNotificationService, ) -from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.const import ( + CONF_NAME, + CONF_PLATFORM, + CONF_PROFILE_NAME, + CONF_SERVICE, +) from homeassistant.helpers.json import JSONEncoder -from .const import ( - CONF_CONTEXT, - CONF_CREDENTIAL_NAME, - CONF_PROFILE_NAME, - CONF_REGION, - CONF_SERVICE, - DATA_SESSIONS, -) +from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_SESSIONS _LOGGER = logging.getLogger(__name__) async def get_available_regions(hass, service): """Get available regions for a service.""" - session = aiobotocore.get_session() # get_available_regions is not a coroutine since it does not perform # network I/O. But it still perform file I/O heavily, so put it into diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index d99c5329e32..c65f663f2b9 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -138,7 +138,6 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): async def async_step_reauth(self, device_config: dict): """Trigger a reauthentication flow.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_NAME: device_config[CONF_NAME], CONF_HOST: device_config[CONF_HOST], @@ -204,7 +203,6 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): } ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_NAME: device[CONF_NAME], CONF_HOST: device[CONF_HOST], diff --git a/homeassistant/components/axis/translations/ko.json b/homeassistant/components/axis/translations/ko.json index f73d467fbf7..d9e0114a97a 100644 --- a/homeassistant/components/axis/translations/ko.json +++ b/homeassistant/components/axis/translations/ko.json @@ -7,9 +7,11 @@ }, "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, - "flow_title": "Axis \uae30\uae30: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/axis/translations/nl.json b/homeassistant/components/axis/translations/nl.json index 483acefec15..345e6622e93 100644 --- a/homeassistant/components/axis/translations/nl.json +++ b/homeassistant/components/axis/translations/nl.json @@ -8,7 +8,8 @@ "error": { "already_configured": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" }, "flow_title": "Axis apparaat: {name} ({host})", "step": { diff --git a/homeassistant/components/axis/translations/ru.json b/homeassistant/components/axis/translations/ru.json index 6d979dc9de0..1bf3e369b65 100644 --- a/homeassistant/components/axis/translations/ru.json +++ b/homeassistant/components/axis/translations/ru.json @@ -9,7 +9,7 @@ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "{name} ({host})", "step": { diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py index e1e7d833926..d7d6f2868c3 100644 --- a/homeassistant/components/azure_devops/config_flow.py +++ b/homeassistant/components/azure_devops/config_flow.py @@ -95,7 +95,6 @@ class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): self._project = user_input[CONF_PROJECT] self._pat = user_input[CONF_PAT] - # pylint: disable=no-member self.context["title_placeholders"] = { "project_url": f"{self._organization}/{self._project}", } diff --git a/homeassistant/components/azure_devops/translations/it.json b/homeassistant/components/azure_devops/translations/it.json index 849e65b933f..4b2f5e0efae 100644 --- a/homeassistant/components/azure_devops/translations/it.json +++ b/homeassistant/components/azure_devops/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/azure_devops/translations/ko.json b/homeassistant/components/azure_devops/translations/ko.json new file mode 100644 index 00000000000..555c548a142 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ko.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/nl.json b/homeassistant/components/azure_devops/translations/nl.json index 9abecd187fe..07dc59e1a56 100644 --- a/homeassistant/components/azure_devops/translations/nl.json +++ b/homeassistant/components/azure_devops/translations/nl.json @@ -1,10 +1,21 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "project_error": "Kon geen projectinformatie ophalen." + }, + "step": { + "user": { + "data": { + "organization": "Organisatie", + "project": "Project" + } + } } } } \ 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 84e0fc93b46..4e59af2dd11 100644 --- a/homeassistant/components/azure_devops/translations/ru.json +++ b/homeassistant/components/azure_devops/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "project_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0435." }, "flow_title": "Azure DevOps: {project_url}", diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 4768b3f4fe6..69553e921eb 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import callback -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import ConditionError, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -340,20 +340,28 @@ class BayesianBinarySensor(BinarySensorEntity): """Return True if numeric condition is met.""" entity = entity_observation["entity_id"] - return condition.async_numeric_state( - self.hass, - entity, - entity_observation.get("below"), - entity_observation.get("above"), - None, - entity_observation, - ) + try: + return condition.async_numeric_state( + self.hass, + entity, + entity_observation.get("below"), + entity_observation.get("above"), + None, + entity_observation, + ) + except ConditionError: + return False def _process_state(self, entity_observation): """Return True if state conditions are met.""" entity = entity_observation["entity_id"] - return condition.state(self.hass, entity, entity_observation.get("to_state")) + try: + return condition.state( + self.hass, entity, entity_observation.get("to_state") + ) + except ConditionError: + return False @property def name(self): diff --git a/homeassistant/components/bayesian/services.yaml b/homeassistant/components/bayesian/services.yaml index ec7313a8630..2fe3a4f7c9b 100644 --- a/homeassistant/components/bayesian/services.yaml +++ b/homeassistant/components/bayesian/services.yaml @@ -1,2 +1,2 @@ reload: - description: Reload all bayesian entities. + description: Reload all bayesian entities diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index f7f0c53a698..b87a761a7a1 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -190,16 +190,13 @@ async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" trigger_type = config[CONF_TYPE] if trigger_type in TURNED_ON: - from_state = "off" to_state = "on" else: - from_state = "on" to_state = "off" state_config = { state_trigger.CONF_PLATFORM: "state", state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state_trigger.CONF_FROM: from_state, state_trigger.CONF_TO: to_state, } if CONF_FOR in config: diff --git a/homeassistant/components/binary_sensor/significant_change.py b/homeassistant/components/binary_sensor/significant_change.py new file mode 100644 index 00000000000..bc2dba04f09 --- /dev/null +++ b/homeassistant/components/binary_sensor/significant_change.py @@ -0,0 +1,20 @@ +"""Helper to test significant Binary Sensor state changes.""" +from typing import Any, Optional + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> Optional[bool]: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + return False diff --git a/homeassistant/components/binary_sensor/translations/tr.json b/homeassistant/components/binary_sensor/translations/tr.json index 94e1496cc30..daf44cc967b 100644 --- a/homeassistant/components/binary_sensor/translations/tr.json +++ b/homeassistant/components/binary_sensor/translations/tr.json @@ -75,8 +75,8 @@ "on": "Tak\u0131l\u0131" }, "presence": { - "off": "[%key:common::state::evde_degil%]", - "on": "[%key:common::state::evde%]" + "off": "D\u0131\u015farda", + "on": "Evde" }, "problem": { "off": "Tamam", diff --git a/homeassistant/components/binary_sensor/translations/zh-Hans.json b/homeassistant/components/binary_sensor/translations/zh-Hans.json index 9254f667a48..a44e16d78e2 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hans.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hans.json @@ -100,8 +100,8 @@ "on": "\u8fc7\u70ed" }, "light": { - "off": "\u6ca1\u6709\u5149\u7ebf", - "on": "\u68c0\u6d4b\u5230\u5149\u7ebf" + "off": "\u65e0\u5149", + "on": "\u6709\u5149" }, "lock": { "off": "\u4e0a\u9501", diff --git a/homeassistant/components/blebox/translations/ko.json b/homeassistant/components/blebox/translations/ko.json index ff3fa740092..81c7bf3af48 100644 --- a/homeassistant/components/blebox/translations/ko.json +++ b/homeassistant/components/blebox/translations/ko.json @@ -2,11 +2,11 @@ "config": { "abort": { "address_already_configured": "BleBox \uae30\uae30\uac00 {address} \ub85c \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_configured": "BleBox \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "BleBox \uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. (\ub85c\uadf8\uc5d0\uc11c \uc624\ub958 \ub0b4\uc6a9\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694.)", - "unknown": "BleBox \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. (\ub85c\uadf8\uc5d0\uc11c \uc624\ub958 \ub0b4\uc6a9\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694.)", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", "unsupported_version": "BleBox \uae30\uae30 \ud38c\uc6e8\uc5b4\uac00 \uc624\ub798\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \uc5c5\uadf8\ub808\uc774\ub4dc\ud574\uc8fc\uc138\uc694." }, "flow_title": "BleBox \uae30\uae30: {name} ({host})", diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 17d737bcaf3..1c91f1a2295 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -2,7 +2,7 @@ "domain": "blink", "name": "Blink", "documentation": "https://www.home-assistant.io/integrations/blink", - "requirements": ["blinkpy==0.16.4"], + "requirements": ["blinkpy==0.17.0"], "codeowners": ["@fronzbot"], "config_flow": true } diff --git a/homeassistant/components/blink/translations/ko.json b/homeassistant/components/blink/translations/ko.json index ac8c96e4f2d..35ef0cefdef 100644 --- a/homeassistant/components/blink/translations/ko.json +++ b/homeassistant/components/blink/translations/ko.json @@ -4,8 +4,8 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_access_token": "\uc798\ubabb\ub41c \uc778\uc99d", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/blink/translations/nl.json b/homeassistant/components/blink/translations/nl.json index c1ab971dbf0..f1f1ce7888b 100644 --- a/homeassistant/components/blink/translations/nl.json +++ b/homeassistant/components/blink/translations/nl.json @@ -4,6 +4,8 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_access_token": "Ongeldig toegangstoken", "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, @@ -23,5 +25,12 @@ "title": "Aanmelden met Blink account" } } + }, + "options": { + "step": { + "simple_options": { + "title": "Blink opties" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/blink/translations/ru.json b/homeassistant/components/blink/translations/ru.json index 0e55fa716b9..0835ab5ac0a 100644 --- a/homeassistant/components/blink/translations/ru.json +++ b/homeassistant/components/blink/translations/ru.json @@ -6,7 +6,7 @@ "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_access_token": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/blink/translations/zh-Hant.json b/homeassistant/components/blink/translations/zh-Hant.json index 3d05dc82abc..d2c42bf5531 100644 --- a/homeassistant/components/blink/translations/zh-Hant.json +++ b/homeassistant/components/blink/translations/zh-Hant.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_access_token": "\u5b58\u53d6\u5bc6\u9470\u7121\u6548", + "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 32fc30b60b9..84931a04310 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -5,7 +5,7 @@ import pathlib import shutil from typing import Any, Dict, List, Optional, Union -from pkg_resources import parse_version +from awesomeversion import AwesomeVersion import voluptuous as vol from voluptuous.humanize import humanize_error @@ -114,7 +114,7 @@ class Blueprint: metadata = self.metadata min_version = metadata.get(CONF_HOMEASSISTANT, {}).get(CONF_MIN_VERSION) - if min_version is not None and parse_version(__version__) < parse_version( + if min_version is not None and AwesomeVersion(__version__) < AwesomeVersion( min_version ): errors.append(f"Requires at least Home Assistant {min_version}") diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 6968d4530cd..05ae2816696 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API for blueprint.""" -import logging from typing import Dict, Optional import async_timeout @@ -15,8 +14,6 @@ from . import importer, models from .const import DOMAIN from .errors import FileAlreadyExists -_LOGGER = logging.getLogger(__package__) - @callback def async_setup(hass: HomeAssistant): diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index af49266bef4..380d8091bd6 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -20,6 +20,7 @@ from homeassistant.components.device_tracker.legacy import ( YAML_DEVICES, async_load_config, ) +from homeassistant.const import CONF_DEVICE_ID import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType @@ -32,8 +33,6 @@ BT_PREFIX = "BT_" CONF_REQUEST_RSSI = "request_rssi" -CONF_DEVICE_ID = "device_id" - DEFAULT_DEVICE_ID = -1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -131,7 +130,6 @@ async def async_setup_scanner( async def perform_bluetooth_update(): """Discover Bluetooth devices and update status.""" - _LOGGER.debug("Performing Bluetooth devices discovery and update") tasks = [] @@ -164,7 +162,6 @@ async def async_setup_scanner( async def update_bluetooth(now=None): """Lookup Bluetooth devices and update status.""" - # If an update is in progress, we don't do anything if update_bluetooth_lock.locked(): _LOGGER.debug( @@ -178,7 +175,6 @@ async def async_setup_scanner( async def handle_manual_update_bluetooth(call): """Update bluetooth devices on demand.""" - await update_bluetooth() hass.async_create_task(update_bluetooth()) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index e9f6a0d7f6f..a8bebfbc617 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,4 +1,6 @@ """Reads vehicle status from BMW connected drive portal.""" +from __future__ import annotations + import asyncio import logging @@ -12,6 +14,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME, CONF_PASSWORD, + CONF_REGION, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback @@ -28,7 +31,6 @@ from .const import ( CONF_ACCOUNT, CONF_ALLOWED_REGIONS, CONF_READ_ONLY, - CONF_REGION, CONF_USE_LOCATION, DATA_ENTRIES, DATA_HASS_CONFIG, @@ -120,7 +122,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): def _update_all() -> None: """Update all BMW accounts.""" - for entry in hass.data[DOMAIN][DATA_ENTRIES].values(): + for entry in hass.data[DOMAIN][DATA_ENTRIES].copy().values(): entry[CONF_ACCOUNT].update() # Add update listener for config entry changes (options) @@ -195,7 +197,7 @@ async def update_listener(hass, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) -def setup_account(entry: ConfigEntry, hass, name: str) -> "BMWConnectedDriveAccount": +def setup_account(entry: ConfigEntry, hass, name: str) -> BMWConnectedDriveAccount: """Set up a new BMWConnectedDriveAccount based on the config.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index a6081d5ccc1..fbfa20aff1a 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,19 +1,14 @@ """Config flow for BMW ConnectedDrive integration.""" -import logging - from bimmer_connected.account import ConnectedDriveAccount from bimmer_connected.country_selector import get_region_from_name import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.core import callback from . import DOMAIN # pylint: disable=unused-import -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REGION, CONF_USE_LOCATION - -_LOGGER = logging.getLogger(__name__) - +from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_USE_LOCATION DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 65dc7fde595..7af24496838 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -1,7 +1,6 @@ """Const file for the BMW Connected Drive integration.""" ATTRIBUTION = "Data provided by BMW Connected Drive" -CONF_REGION = "region" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_USE_LOCATION = "use_location" diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 7f069e741b8..25adf6cb09f 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -42,12 +42,12 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): @property def latitude(self): """Return latitude value of the device.""" - return self._location[0] + return self._location[0] if self._location else None @property def longitude(self): """Return longitude value of the device.""" - return self._location[1] + return self._location[1] if self._location else None @property def name(self): diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c1d90f713f4..bbff139187e 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.14"], + "requirements": ["bimmer_connected==0.7.15"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true } diff --git a/homeassistant/components/bmw_connected_drive/translations/fr.json b/homeassistant/components/bmw_connected_drive/translations/fr.json index 1b8f562669f..900b352ecb6 100644 --- a/homeassistant/components/bmw_connected_drive/translations/fr.json +++ b/homeassistant/components/bmw_connected_drive/translations/fr.json @@ -21,7 +21,8 @@ "step": { "account_options": { "data": { - "read_only": "Lecture seule (uniquement capteurs et notification, pas d'ex\u00e9cution de services, pas de verrouillage)" + "read_only": "Lecture seule (uniquement capteurs et notification, pas d'ex\u00e9cution de services, pas de verrouillage)", + "use_location": "Utilisez la localisation de Home Assistant pour les sondages de localisation de voiture (obligatoire pour les v\u00e9hicules non i3 / i8 produits avant 7/2014)" } } } diff --git a/homeassistant/components/bmw_connected_drive/translations/ko.json b/homeassistant/components/bmw_connected_drive/translations/ko.json new file mode 100644 index 00000000000..9cc079cf1cd --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/nl.json b/homeassistant/components/bmw_connected_drive/translations/nl.json new file mode 100644 index 00000000000..83ae0b9ff7d --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "region": "ConnectedDrive-regio", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/ru.json b/homeassistant/components/bmw_connected_drive/translations/ru.json index 0840affcef4..9ac76bbea9e 100644 --- a/homeassistant/components/bmw_connected_drive/translations/ru.json +++ b/homeassistant/components/bmw_connected_drive/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 9af92f6e7e7..9d0a613000a 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -3,16 +3,16 @@ import asyncio from asyncio import TimeoutError as AsyncIOTimeoutError from aiohttp import ClientError, ClientTimeout -from bond_api import Bond +from bond_api import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import SLOW_UPDATE_WARNING -from .const import DOMAIN +from .const import BPUP_STOP, BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB from .utils import BondHub PLATFORMS = ["cover", "fan", "light", "switch"] @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Bond from a config entry.""" host = entry.data[CONF_HOST] token = entry.data[CONF_ACCESS_TOKEN] + config_entry_id = entry.entry_id bond = Bond(host=host, token=token, timeout=ClientTimeout(total=_API_TIMEOUT)) hub = BondHub(bond) @@ -37,21 +38,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except (ClientError, AsyncIOTimeoutError, OSError) as error: raise ConfigEntryNotReady from error - hass.data[DOMAIN][entry.entry_id] = hub + bpup_subs = BPUPSubscriptions() + stop_bpup = await start_bpup(host, bpup_subs) + + hass.data[DOMAIN][entry.entry_id] = { + HUB: hub, + BPUP_SUBS: bpup_subs, + BPUP_STOP: stop_bpup, + } if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) + hub_name = hub.name or hub.bond_id device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( - config_entry_id=entry.entry_id, + config_entry_id=config_entry_id, identifiers={(DOMAIN, hub.bond_id)}, - manufacturer="Olibra", - name=hub.bond_id, + manufacturer=BRIDGE_MAKE, + name=hub_name, model=hub.target, sw_version=hub.fw_ver, + suggested_area=hub.location, ) + _async_remove_old_device_identifiers(config_entry_id, device_registry, hub) + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) @@ -71,7 +83,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + data = hass.data[DOMAIN][entry.entry_id] + if BPUP_STOP in data: + data[BPUP_STOP]() + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def _async_remove_old_device_identifiers( + config_entry_id: str, device_registry: dr.DeviceRegistry, hub: BondHub +): + """Remove the non-unique device registry entries.""" + for device in hub.devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) + if dev is None: + continue + if config_entry_id in dev.config_entries: + device_registry.async_remove_device(dev.id) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6666cd57ca3..f81e3a0be5c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Bond integration.""" import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple from aiohttp import ClientConnectionError, ClientResponseError from bond_api import Bond @@ -14,25 +14,26 @@ from homeassistant.const import ( HTTP_UNAUTHORIZED, ) -from .const import CONF_BOND_ID from .const import DOMAIN # pylint:disable=unused-import +from .utils import BondHub _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA_USER = vol.Schema( + +USER_SCHEMA = vol.Schema( {vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str} ) -DATA_SCHEMA_DISCOVERY = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DISCOVERY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +TOKEN_SCHEMA = vol.Schema({}) -async def _validate_input(data: Dict[str, Any]) -> str: +async def _validate_input(data: Dict[str, Any]) -> Tuple[str, Optional[str]]: """Validate the user input allows us to connect.""" + bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) try: - bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) - version = await bond.version() - # call to non-version API is needed to validate authentication - await bond.devices() + hub = BondHub(bond) + await hub.setup(max_devices=1) except ClientConnectionError as error: raise InputValidationError("cannot_connect") from error except ClientResponseError as error: @@ -44,20 +45,42 @@ async def _validate_input(data: Dict[str, Any]) -> str: raise InputValidationError("unknown") from error # Return unique ID from the hub to be stored in the config entry. - bond_id = version.get("bondid") - if not bond_id: + if not hub.bond_id: raise InputValidationError("old_firmware") - return bond_id + return hub.bond_id, hub.name class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Bond.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - _discovered: dict = None + def __init__(self): + """Initialize config flow.""" + self._discovered: dict = None + + async def _async_try_automatic_configure(self): + """Try to auto configure the device. + + Failure is acceptable here since the device may have been + online longer then the allowed setup period, and we will + instead ask them to manually enter the token. + """ + bond = Bond(self._discovered[CONF_HOST], "") + try: + response = await bond.token() + except ClientConnectionError: + return + + token = response.get("token") + if token is None: + return + + self._discovered[CONF_ACCESS_TOKEN] = token + _, hub_name = await _validate_input(self._discovered) + self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( self, discovery_info: Optional[Dict[str, Any]] = None @@ -69,12 +92,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(bond_id) self._abort_if_unique_id_configured({CONF_HOST: host}) - self._discovered = { - CONF_HOST: host, - CONF_BOND_ID: bond_id, - } - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - self.context.update({"title_placeholders": self._discovered}) + self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} + await self._async_try_automatic_configure() + + self.context.update( + { + "title_placeholders": { + CONF_HOST: self._discovered[CONF_HOST], + CONF_NAME: self._discovered[CONF_NAME], + } + } + ) return await self.async_step_confirm() @@ -84,16 +112,37 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle confirmation flow for discovered bond hub.""" errors = {} if user_input is not None: - data = user_input.copy() - data[CONF_HOST] = self._discovered[CONF_HOST] + if CONF_ACCESS_TOKEN in self._discovered: + return self.async_create_entry( + title=self._discovered[CONF_NAME], + data={ + CONF_ACCESS_TOKEN: self._discovered[CONF_ACCESS_TOKEN], + CONF_HOST: self._discovered[CONF_HOST], + }, + ) + + data = { + CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN], + CONF_HOST: self._discovered[CONF_HOST], + } try: - return await self._try_create_entry(data) + _, hub_name = await _validate_input(data) except InputValidationError as error: errors["base"] = error.base + else: + return self.async_create_entry( + title=hub_name, + data=data, + ) + + if CONF_ACCESS_TOKEN in self._discovered: + data_schema = TOKEN_SCHEMA + else: + data_schema = DISCOVERY_SCHEMA return self.async_show_form( step_id="confirm", - data_schema=DATA_SCHEMA_DISCOVERY, + data_schema=data_schema, errors=errors, description_placeholders=self._discovered, ) @@ -105,20 +154,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - return await self._try_create_entry(user_input) + bond_id, hub_name = await _validate_input(user_input) except InputValidationError as error: errors["base"] = error.base + else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors + step_id="user", data_schema=USER_SCHEMA, errors=errors ) - async def _try_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]: - bond_id = await _validate_input(data) - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=bond_id, data=data) - class InputValidationError(exceptions.HomeAssistantError): """Error to indicate we cannot proceed due to invalid input.""" diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 843c3f9f1dc..818288a5764 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -1,5 +1,12 @@ """Constants for the Bond integration.""" +BRIDGE_MAKE = "Olibra" + DOMAIN = "bond" CONF_BOND_ID: str = "bond_id" + + +HUB = "hub" +BPUP_SUBS = "bpup_subs" +BPUP_STOP = "bpup_stop" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index dc0fc6d500c..6b3c8d6bc02 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,14 +1,14 @@ """Support for Bond covers.""" from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -19,10 +19,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond cover devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] covers = [ - BondCover(hub, device) + BondCover(hub, device, bpup_subs) for device in hub.devices if device.type == DeviceType.MOTORIZED_SHADES ] @@ -33,9 +35,9 @@ async def async_setup_entry( class BondCover(BondEntity, CoverEntity): """Representation of a Bond cover.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond cover.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._closed: Optional[bool] = None diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 501d6574960..5b2e27b94cc 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,53 +1,90 @@ """An abstract class common to all Bond entities.""" from abc import abstractmethod -from asyncio import TimeoutError as AsyncIOTimeoutError +from asyncio import Lock, TimeoutError as AsyncIOTimeoutError +from datetime import timedelta import logging from typing import Any, Dict, Optional from aiohttp import ClientError +from bond_api import BPUPSubscriptions from homeassistant.const import ATTR_NAME +from homeassistant.core import callback from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) +_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) + class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" def __init__( - self, hub: BondHub, device: BondDevice, sub_device: Optional[str] = None + self, + hub: BondHub, + device: BondDevice, + bpup_subs: BPUPSubscriptions, + sub_device: Optional[str] = None, ): """Initialize entity with API and device info.""" self._hub = hub self._device = device + self._device_id = device.device_id self._sub_device = sub_device self._available = True + self._bpup_subs = bpup_subs + self._update_lock = None + self._initialized = False @property def unique_id(self) -> Optional[str]: """Get unique ID for the entity.""" hub_id = self._hub.bond_id - device_id = self._device.device_id + device_id = self._device_id sub_device_id: str = f"_{self._sub_device}" if self._sub_device else "" return f"{hub_id}_{device_id}{sub_device_id}" @property def name(self) -> Optional[str]: """Get entity name.""" + if self._sub_device: + sub_device_name = self._sub_device.replace("_", " ").title() + return f"{self._device.name} {sub_device_name}" return self._device.name + @property + def should_poll(self): + """No polling needed.""" + return False + @property def device_info(self) -> Optional[Dict[str, Any]]: """Get a an HA device representing this Bond controlled device.""" - return { + device_info = { ATTR_NAME: self.name, - "identifiers": {(DOMAIN, self._device.device_id)}, + "manufacturer": self._hub.make, + "identifiers": {(DOMAIN, self._hub.bond_id, self._device.device_id)}, + "suggested_area": self._device.location, "via_device": (DOMAIN, self._hub.bond_id), } + if not self._hub.is_bridge: + device_info["model"] = self._hub.model + device_info["sw_version"] = self._hub.fw_ver + else: + model_data = [] + if self._device.branding_profile: + model_data.append(self._device.branding_profile) + if self._device.template: + model_data.append(self._device.template) + if model_data: + device_info["model"] = " ".join(model_data) + + return device_info @property def assumed_state(self) -> bool: @@ -61,8 +98,29 @@ class BondEntity(Entity): async def async_update(self): """Fetch assumed state of the cover from the hub using API.""" + await self._async_update_from_api() + + async def _async_update_if_bpup_not_alive(self, *_): + """Fetch via the API if BPUP is not alive.""" + if self._bpup_subs.alive and self._initialized: + return + + if self._update_lock.locked(): + _LOGGER.warning( + "Updating %s took longer than the scheduled update interval %s", + self.entity_id, + _FALLBACK_SCAN_INTERVAL, + ) + return + + async with self._update_lock: + await self._async_update_from_api() + self.async_write_ha_state() + + async def _async_update_from_api(self): + """Fetch via the API.""" try: - state: dict = await self._hub.bond.device_state(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device_id) except (ClientError, AsyncIOTimeoutError, OSError) as error: if self._available: _LOGGER.warning( @@ -70,12 +128,42 @@ class BondEntity(Entity): ) self._available = False else: - _LOGGER.debug("Device state for %s is:\n%s", self.entity_id, state) - if not self._available: - _LOGGER.info("Entity %s has come back", self.entity_id) - self._available = True - self._apply_state(state) + self._async_state_callback(state) @abstractmethod def _apply_state(self, state: dict): raise NotImplementedError + + @callback + def _async_state_callback(self, state): + """Process a state change.""" + self._initialized = True + if not self._available: + _LOGGER.info("Entity %s has come back", self.entity_id) + self._available = True + _LOGGER.debug( + "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state + ) + self._apply_state(state) + + @callback + def _async_bpup_callback(self, state): + """Process a state change from BPUP.""" + self._async_state_callback(state) + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to BPUP and start polling.""" + await super().async_added_to_hass() + self._update_lock = Lock() + self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from BPUP data on remove.""" + await super().async_will_remove_from_hass() + self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index e59d0234beb..5ff7e0c7065 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -1,17 +1,13 @@ """Support for Bond fans.""" import logging import math -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, Tuple -from bond_api import Action, DeviceType, Direction +from bond_api import Action, BPUPSubscriptions, DeviceType, Direction from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity, @@ -19,8 +15,13 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -33,10 +34,14 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond fan devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] fans = [ - BondFan(hub, device) for device in hub.devices if DeviceType.is_fan(device.type) + BondFan(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_fan(device.type) ] async_add_entities(fans, True) @@ -45,9 +50,9 @@ async def async_setup_entry( class BondFan(BondEntity, FanEntity): """Representation of a Bond fan.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond fan.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None self._speed: Optional[int] = None @@ -70,22 +75,21 @@ class BondFan(BondEntity, FanEntity): return features @property - def speed(self) -> Optional[str]: - """Return the current speed.""" - if self._power == 0: - return SPEED_OFF - if not self._power or not self._speed: - return None - - # map 1..max_speed Bond speed to 1..3 HA speed - max_speed = max(self._device.props.get("max_speed", 3), self._speed) - ha_speed = math.ceil(self._speed * (len(self.speed_list) - 1) / max_speed) - return self.speed_list[ha_speed] + def _speed_range(self) -> Tuple[int, int]: + """Return the range of speeds.""" + return (1, self._device.props.get("max_speed", 3)) @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + def percentage(self) -> Optional[str]: + """Return the current speed percentage for the fan.""" + if not self._speed or not self._power: + return 0 + return ranged_value_to_percentage(self._speed_range, self._speed) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self._speed_range) @property def current_direction(self) -> Optional[str]: @@ -98,35 +102,39 @@ class BondFan(BondEntity, FanEntity): return direction - async def async_set_speed(self, speed: str) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the desired speed for the fan.""" - _LOGGER.debug("async_set_speed called with speed %s", speed) + _LOGGER.debug("async_set_percentage called with percentage %s", percentage) - if speed == SPEED_OFF: + if percentage == 0: await self.async_turn_off() return - max_speed = self._device.props.get("max_speed", 3) - if speed == SPEED_LOW: - bond_speed = 1 - elif speed == SPEED_HIGH: - bond_speed = max_speed - else: - bond_speed = math.ceil(max_speed / 2) + bond_speed = math.ceil( + percentage_to_ranged_value(self._speed_range, percentage) + ) + _LOGGER.debug( + "async_set_percentage converted percentage %s to bond speed %s", + percentage, + bond_speed, + ) await self._hub.bond.action( self._device.device_id, Action.set_speed(bond_speed) ) - async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" - _LOGGER.debug("Fan async_turn_on called with speed %s", speed) + _LOGGER.debug("Fan async_turn_on called with percentage %s", percentage) - if speed is not None: - if speed == SPEED_OFF: - await self.async_turn_off() - else: - await self.async_set_speed(speed) + if percentage is not None: + await self.async_set_percentage(percentage) else: await self._hub.bond.action(self._device.device_id, Action.turn_on()) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 77771167e14..194a009a857 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -2,7 +2,7 @@ import logging from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from . import BondHub -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice @@ -27,40 +27,93 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond light devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] fan_lights: List[Entity] = [ - BondLight(hub, device) + BondLight(hub, device, bpup_subs) for device in hub.devices - if DeviceType.is_fan(device.type) and device.supports_light() + if DeviceType.is_fan(device.type) + and device.supports_light() + and not (device.supports_up_light() and device.supports_down_light()) + ] + + fan_up_lights: List[Entity] = [ + BondUpLight(hub, device, bpup_subs, "up_light") + for device in hub.devices + if DeviceType.is_fan(device.type) and device.supports_up_light() + ] + + fan_down_lights: List[Entity] = [ + BondDownLight(hub, device, bpup_subs, "down_light") + for device in hub.devices + if DeviceType.is_fan(device.type) and device.supports_down_light() ] fireplaces: List[Entity] = [ - BondFireplace(hub, device) + BondFireplace(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_fireplace(device.type) ] fp_lights: List[Entity] = [ - BondLight(hub, device, "light") + BondLight(hub, device, bpup_subs, "light") for device in hub.devices if DeviceType.is_fireplace(device.type) and device.supports_light() ] - async_add_entities(fan_lights + fireplaces + fp_lights, True) + lights: List[Entity] = [ + BondLight(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_light(device.type) + ] + + async_add_entities( + fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, + True, + ) -class BondLight(BondEntity, LightEntity): +class BondBaseLight(BondEntity, LightEntity): """Representation of a Bond light.""" def __init__( - self, hub: BondHub, device: BondDevice, sub_device: Optional[str] = None + self, + hub: BondHub, + device: BondDevice, + bpup_subs: BPUPSubscriptions, + sub_device: Optional[str] = None, ): - """Create HA entity representing Bond fan.""" - super().__init__(hub, device, sub_device) - self._brightness: Optional[int] = None + """Create HA entity representing Bond light.""" + super().__init__(hub, device, bpup_subs, sub_device) self._light: Optional[int] = None + @property + def is_on(self) -> bool: + """Return if light is currently on.""" + return self._light == 1 + + @property + def supported_features(self) -> Optional[int]: + """Flag supported features.""" + return 0 + + +class BondLight(BondBaseLight, BondEntity, LightEntity): + """Representation of a Bond light.""" + + def __init__( + self, + hub: BondHub, + device: BondDevice, + bpup_subs: BPUPSubscriptions, + sub_device: Optional[str] = None, + ): + """Create HA entity representing Bond light.""" + super().__init__(hub, device, bpup_subs, sub_device) + self._brightness: Optional[int] = None + def _apply_state(self, state: dict): self._light = state.get("light") self._brightness = state.get("brightness") @@ -72,11 +125,6 @@ class BondLight(BondEntity, LightEntity): return SUPPORT_BRIGHTNESS return 0 - @property - def is_on(self) -> bool: - """Return if light is currently on.""" - return self._light == 1 - @property def brightness(self) -> int: """Return the brightness of this light between 1..255.""" @@ -101,12 +149,50 @@ class BondLight(BondEntity, LightEntity): await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) +class BondDownLight(BondBaseLight, BondEntity, LightEntity): + """Representation of a Bond light.""" + + def _apply_state(self, state: dict): + self._light = state.get("down_light") and state.get("light") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self._hub.bond.action( + self._device.device_id, Action(Action.TURN_DOWN_LIGHT_ON) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._hub.bond.action( + self._device.device_id, Action(Action.TURN_DOWN_LIGHT_OFF) + ) + + +class BondUpLight(BondBaseLight, BondEntity, LightEntity): + """Representation of a Bond light.""" + + def _apply_state(self, state: dict): + self._light = state.get("up_light") and state.get("light") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self._hub.bond.action( + self._device.device_id, Action(Action.TURN_UP_LIGHT_ON) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._hub.bond.action( + self._device.device_id, Action(Action.TURN_UP_LIGHT_OFF) + ) + + class BondFireplace(BondEntity, LightEntity): """Representation of a Bond-controlled fireplace.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond fireplace.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None # Bond flame level, 0-100 diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3f62403dba7..65cb6a83bb2 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.8"], + "requirements": ["bond-api==0.1.11"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@prystupa"], "quality_scale": "platinum" diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 5ca2278a3e5..f8eff6ddd9e 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -1,9 +1,9 @@ { "config": { - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { - "description": "Do you want to set up {bond_id}?", + "description": "Do you want to set up {name}?", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index d2f1797225d..8319d31c714 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -1,14 +1,14 @@ """Support for Bond generic devices.""" from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -19,10 +19,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond generic devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] switches = [ - BondSwitch(hub, device) + BondSwitch(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_generic(device.type) ] @@ -33,9 +35,9 @@ async def async_setup_entry( class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond generic device (switch).""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None diff --git a/homeassistant/components/bond/translations/ca.json b/homeassistant/components/bond/translations/ca.json index 3903ea77c34..1d1df915630 100644 --- a/homeassistant/components/bond/translations/ca.json +++ b/homeassistant/components/bond/translations/ca.json @@ -9,13 +9,13 @@ "old_firmware": "Hi ha un programari antic i no compatible al dispositiu Bond - actualitza'l abans de continuar", "unknown": "Error inesperat" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { "data": { "access_token": "Token d'acc\u00e9s" }, - "description": "Vols configurar {bond_id}?" + "description": "Vols configurar {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/cs.json b/homeassistant/components/bond/translations/cs.json index 677c7e80236..13135dbf53e 100644 --- a/homeassistant/components/bond/translations/cs.json +++ b/homeassistant/components/bond/translations/cs.json @@ -15,7 +15,7 @@ "data": { "access_token": "P\u0159\u00edstupov\u00fd token" }, - "description": "Chcete nastavit {bond_id} ?" + "description": "Chcete nastavit {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index 945b09b8186..d9ce8ab0fe4 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -9,13 +9,13 @@ "old_firmware": "Unsupported old firmware on the Bond device - please upgrade before continuing", "unknown": "Unexpected error" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { "data": { "access_token": "Access Token" }, - "description": "Do you want to set up {bond_id}?" + "description": "Do you want to set up {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/et.json b/homeassistant/components/bond/translations/et.json index dc6a8414bce..5e9a8e4493f 100644 --- a/homeassistant/components/bond/translations/et.json +++ b/homeassistant/components/bond/translations/et.json @@ -9,13 +9,13 @@ "old_firmware": "Bondi seadme ei toeta vana p\u00fcsivara - uuenda enne j\u00e4tkamist", "unknown": "Tundmatu viga" }, - "flow_title": "Bond: {bond_id} ( {host} )", + "flow_title": "Bond: {name} ( {host} )", "step": { "confirm": { "data": { "access_token": "Juurdep\u00e4\u00e4sut\u00f5end" }, - "description": "Kas soovid seadistada teenuse {bond_id} ?" + "description": "Kas soovid seadistada teenust {name} ?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/fr.json b/homeassistant/components/bond/translations/fr.json index 496a21339cb..d9eb14b1a62 100644 --- a/homeassistant/components/bond/translations/fr.json +++ b/homeassistant/components/bond/translations/fr.json @@ -9,13 +9,13 @@ "old_firmware": "Ancien micrologiciel non pris en charge sur l'appareil Bond - veuillez mettre \u00e0 niveau avant de continuer", "unknown": "Erreur inattendue" }, - "flow_title": "Bond : {bond_id} ({h\u00f4te})", + "flow_title": "Lien : {name} ({host})", "step": { "confirm": { "data": { "access_token": "Jeton d'acc\u00e8s" }, - "description": "Voulez-vous configurer {bond_id} ?" + "description": "Voulez-vous configurer {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/it.json b/homeassistant/components/bond/translations/it.json index d3ac1ab6b49..e22ad82e1fd 100644 --- a/homeassistant/components/bond/translations/it.json +++ b/homeassistant/components/bond/translations/it.json @@ -9,13 +9,13 @@ "old_firmware": "Firmware precedente non supportato sul dispositivo Bond - si prega di aggiornare prima di continuare", "unknown": "Errore imprevisto" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { "data": { "access_token": "Token di accesso" }, - "description": "Vuoi configurare {bond_id}?" + "description": "Vuoi configurare {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/ko.json b/homeassistant/components/bond/translations/ko.json index 61576d70431..b44db53f7c8 100644 --- a/homeassistant/components/bond/translations/ko.json +++ b/homeassistant/components/bond/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "error": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", @@ -8,6 +11,9 @@ "flow_title": "\ubcf8\ub4dc : {bond_id} ( {host} )", "step": { "confirm": { + "data": { + "access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070" + }, "description": "{bond_id} \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" }, "user": { diff --git a/homeassistant/components/bond/translations/nl.json b/homeassistant/components/bond/translations/nl.json index 8010dfc2e78..a76c7a69d7f 100644 --- a/homeassistant/components/bond/translations/nl.json +++ b/homeassistant/components/bond/translations/nl.json @@ -10,8 +10,14 @@ }, "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Toegangstoken" + } + }, "user": { "data": { + "access_token": "Toegangstoken", "host": "Host" } } diff --git a/homeassistant/components/bond/translations/no.json b/homeassistant/components/bond/translations/no.json index 01ff745eed3..c09b7a17635 100644 --- a/homeassistant/components/bond/translations/no.json +++ b/homeassistant/components/bond/translations/no.json @@ -9,13 +9,13 @@ "old_firmware": "Gammel fastvare som ikke st\u00f8ttes p\u00e5 Bond-enheten \u2013 vennligst oppgrader f\u00f8r du fortsetter", "unknown": "Uventet feil" }, - "flow_title": "", + "flow_title": "Obligasjon: {name} ({host})", "step": { "confirm": { "data": { "access_token": "Tilgangstoken" }, - "description": "Vil du konfigurere {bond_id}?" + "description": "Vil du konfigurere {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/pl.json b/homeassistant/components/bond/translations/pl.json index c50c270b74c..6f5f2d276ff 100644 --- a/homeassistant/components/bond/translations/pl.json +++ b/homeassistant/components/bond/translations/pl.json @@ -9,13 +9,13 @@ "old_firmware": "Stare, nieobs\u0142ugiwane oprogramowanie na urz\u0105dzeniu Bond - zaktualizuj przed kontynuowaniem", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { "data": { "access_token": "Token dost\u0119pu" }, - "description": "Czy chcesz skonfigurowa\u0107 {bond_id}?" + "description": "Czy chcesz skonfigurowa\u0107 {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/ru.json b/homeassistant/components/bond/translations/ru.json index 493b8e141ce..cdc37fc27f7 100644 --- a/homeassistant/components/bond/translations/ru.json +++ b/homeassistant/components/bond/translations/ru.json @@ -5,17 +5,17 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "old_firmware": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u0440\u043e\u0448\u0438\u0432\u043a\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f \u0443\u0441\u0442\u0430\u0440\u0435\u043b\u0430 \u0438 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Bond {bond_id} ({host})", + "flow_title": "Bond: {name} ({host})", "step": { "confirm": { "data": { "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430" }, - "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 {bond_id}?" + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/zh-Hant.json b/homeassistant/components/bond/translations/zh-Hant.json index af652c54509..8bb8e178869 100644 --- a/homeassistant/components/bond/translations/zh-Hant.json +++ b/homeassistant/components/bond/translations/zh-Hant.json @@ -9,17 +9,17 @@ "old_firmware": "Bond \u88dd\u7f6e\u4f7f\u7528\u4e0d\u652f\u63f4\u7684\u820a\u7248\u672c\u97cc\u9ad4 - \u8acb\u66f4\u65b0\u5f8c\u518d\u7e7c\u7e8c", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "Bond\uff1a{bond_id} ({host})", + "flow_title": "Bond\uff1a{name} ({host})", "step": { "confirm": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470" + "access_token": "\u5b58\u53d6\u6b0a\u6756" }, - "description": "\u662f\u5426\u8981\u8a2d\u5b9a {bond_id}\uff1f" + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" }, "user": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470", + "access_token": "\u5b58\u53d6\u6b0a\u6756", "host": "\u4e3b\u6a5f\u7aef" } } diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 5a9fff692fa..225eec87d98 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -1,9 +1,13 @@ """Reusable utilities for the Bond component.""" +import asyncio import logging -from typing import List, Optional +from typing import List, Optional, Set +from aiohttp import ClientResponseError from bond_api import Action, Bond +from .const import BRIDGE_MAKE + _LOGGER = logging.getLogger(__name__) @@ -34,36 +38,59 @@ class BondDevice: """Get the type of this device.""" return self._attrs["type"] + @property + def location(self) -> str: + """Get the location of this device.""" + return self._attrs.get("location") + + @property + def template(self) -> str: + """Return this model template.""" + return self._attrs.get("template") + + @property + def branding_profile(self) -> str: + """Return this branding profile.""" + return self.props.get("branding_profile") + @property def trust_state(self) -> bool: """Check if Trust State is turned on.""" return self.props.get("trust_state", False) + def _has_any_action(self, actions: Set[str]): + """Check to see if the device supports any of the actions.""" + supported_actions: List[str] = self._attrs["actions"] + for action in supported_actions: + if action in actions: + return True + return False + def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" - actions: List[str] = self._attrs["actions"] - return bool([action for action in actions if action in [Action.SET_SPEED]]) + return self._has_any_action({Action.SET_SPEED}) def supports_direction(self) -> bool: """Return True if this device supports any of the direction related commands.""" - actions: List[str] = self._attrs["actions"] - return bool([action for action in actions if action in [Action.SET_DIRECTION]]) + return self._has_any_action({Action.SET_DIRECTION}) def supports_light(self) -> bool: """Return True if this device supports any of the light related commands.""" - actions: List[str] = self._attrs["actions"] - return bool( - [ - action - for action in actions - if action in [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF] - ] + return self._has_any_action({Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF}) + + def supports_up_light(self) -> bool: + """Return true if the device has an up light.""" + return self._has_any_action({Action.TURN_UP_LIGHT_ON, Action.TURN_UP_LIGHT_OFF}) + + def supports_down_light(self) -> bool: + """Return true if the device has a down light.""" + return self._has_any_action( + {Action.TURN_DOWN_LIGHT_ON, Action.TURN_DOWN_LIGHT_OFF} ) def supports_set_brightness(self) -> bool: """Return True if this device supports setting a light brightness.""" - actions: List[str] = self._attrs["actions"] - return bool([action for action in actions if action in [Action.SET_BRIGHTNESS]]) + return self._has_any_action({Action.SET_BRIGHTNESS}) class BondHub: @@ -72,37 +99,70 @@ class BondHub: def __init__(self, bond: Bond): """Initialize Bond Hub.""" self.bond: Bond = bond + self._bridge: Optional[dict] = None self._version: Optional[dict] = None self._devices: Optional[List[BondDevice]] = None - async def setup(self): + async def setup(self, max_devices=None): """Read hub version information.""" self._version = await self.bond.version() _LOGGER.debug("Bond reported the following version info: %s", self._version) - # Fetch all available devices using Bond API. device_ids = await self.bond.devices() - self._devices = [ - BondDevice( - device_id, - await self.bond.device(device_id), - await self.bond.device_properties(device_id), + self._devices = [] + for idx, device_id in enumerate(device_ids): + if max_devices is not None and idx >= max_devices: + break + + device, props = await asyncio.gather( + self.bond.device(device_id), self.bond.device_properties(device_id) ) - for device_id in device_ids - ] + + self._devices.append(BondDevice(device_id, device, props)) _LOGGER.debug("Discovered Bond devices: %s", self._devices) + try: + # Smart by bond devices do not have a bridge api call + self._bridge = await self.bond.bridge() + except ClientResponseError: + self._bridge = {} + _LOGGER.debug("Bond reported the following bridge info: %s", self._bridge) @property - def bond_id(self) -> str: + def bond_id(self) -> Optional[str]: """Return unique Bond ID for this hub.""" - return self._version["bondid"] + # Old firmwares are missing the bondid + return self._version.get("bondid") @property def target(self) -> str: - """Return this hub model.""" + """Return this hub target.""" return self._version.get("target") + @property + def model(self) -> str: + """Return this hub model.""" + return self._version.get("model") + + @property + def make(self) -> str: + """Return this hub make.""" + return self._version.get("make", BRIDGE_MAKE) + + @property + def name(self) -> str: + """Get the name of this bridge.""" + if not self.is_bridge and self._devices: + return self._devices[0].name + return self._bridge["name"] + + @property + def location(self) -> Optional[str]: + """Get the location of this bridge.""" + if not self.is_bridge and self._devices: + return self._devices[0].location + return self._bridge.get("location") + @property def fw_ver(self) -> str: """Return this hub firmware version.""" @@ -116,5 +176,4 @@ class BondHub: @property def is_bridge(self) -> bool: """Return if the Bond is a Bond Bridge.""" - # If False, it means that it is a Smart by Bond product. Assumes that it is if the model is not available. - return self._version.get("model", "BD-").startswith("BD-") + return bool(self._bridge) diff --git a/homeassistant/components/braviatv/translations/et.json b/homeassistant/components/braviatv/translations/et.json index b69844ee839..6930186aeba 100644 --- a/homeassistant/components/braviatv/translations/et.json +++ b/homeassistant/components/braviatv/translations/et.json @@ -21,7 +21,7 @@ "data": { "host": "" }, - "description": "Seadista Sony Bravia TV sidumine. Kuion probleeme seadetega mine: https://www.home-assistant.io/integrations/braviatv \n\nVeenduge, et teler on sisse l\u00fclitatud.", + "description": "Seadista Sony Bravia TV sidumine. Kuion probleeme seadetega mine: https://www.home-assistant.io/integrations/braviatv \n\nVeendu, et teler on sisse l\u00fclitatud.", "title": "" } } diff --git a/homeassistant/components/braviatv/translations/ko.json b/homeassistant/components/braviatv/translations/ko.json index 3a82c38f904..0bfb6b3f1b2 100644 --- a/homeassistant/components/braviatv/translations/ko.json +++ b/homeassistant/components/braviatv/translations/ko.json @@ -1,16 +1,19 @@ { "config": { "abort": { - "already_configured": "\uc774 TV \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_ip_control": "TV \uc5d0\uc11c IP \uc81c\uc5b4\uac00 \ube44\ud65c\uc131\ud654\ub418\uc5c8\uac70\ub098 TV \uac00 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8 \ub610\ub294 PIN \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "invalid_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unsupported_model": "\uc774 TV \ubaa8\ub378\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "step": { "authorize": { + "data": { + "pin": "PIN \ucf54\ub4dc" + }, "description": "Sony Bravia TV \uc5d0 \ud45c\uc2dc\ub41c PIN \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\nPIN \ucf54\ub4dc\uac00 \ud45c\uc2dc\ub418\uc9c0 \uc54a\uc73c\uba74 TV \uc5d0\uc11c Home Assistant \ub97c \ub4f1\ub85d \ud574\uc81c\ud558\uc5ec\uc57c \ud569\ub2c8\ub2e4. Settings -> Network -> Remote device settings -> Unregister remote device \ub85c \uc774\ub3d9\ud558\uc5ec \ub4f1\ub85d\uc744 \ud574\uc81c\ud574\uc8fc\uc138\uc694.", "title": "Sony Bravia TV \uc2b9\uc778\ud558\uae30" }, diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index a2e770d6c4f..a309e4eb603 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -57,7 +57,6 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self.device = device - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { "name": device.name, "model": device.model, diff --git a/homeassistant/components/broadlink/translations/ko.json b/homeassistant/components/broadlink/translations/ko.json index b27391e1100..13cd17a8475 100644 --- a/homeassistant/components/broadlink/translations/ko.json +++ b/homeassistant/components/broadlink/translations/ko.json @@ -1,16 +1,17 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "\uc774 \uae30\uae30\uc5d0 \ub300\ud574 \uc774\ubbf8 \uc9c4\ud589\uc911\uc778 \uad6c\uc131\uc774 \uc788\uc2b5\ub2c8\ub2e4.", - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_host": "\uc798\ubabb\ub41c \ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "not_supported": "\uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uc7a5\uce58", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "{name} ({host} \uc758 {model})", "step": { @@ -18,6 +19,9 @@ "title": "\uc7a5\uce58\uc5d0 \uc778\uc99d" }, "finish": { + "data": { + "name": "\uc774\ub984" + }, "title": "\uc7a5\uce58 \uc774\ub984\uc744 \uc120\ud0dd\ud558\uc2ed\uc2dc\uc624" }, "reset": { @@ -27,11 +31,12 @@ "data": { "unlock": "\uc608" }, - "description": "\uc7a5\uce58\uac00 \uc7a0\uaca8 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub85c \uc778\ud574 Home Assistant\uc5d0\uc11c \uc778\uc99d \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc7a0\uae08\uc744 \ud574\uc81c \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "{name} ({host} \uc758 {model}) \uc774(\uac00) \uc7a0\uaca8 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub85c \uc778\ud574 Home Assistant \uc5d0\uc11c \uc778\uc99d \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc7a0\uae08\uc744 \ud574\uc81c\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "\uc7a5\uce58 \uc7a0\uae08 \ud574\uc81c (\uc635\uc158)" }, "user": { "data": { + "host": "\ud638\uc2a4\ud2b8", "timeout": "\uc81c\ud55c \uc2dc\uac04" }, "title": "\uc7a5\uce58\uc5d0 \uc5f0\uacb0" diff --git a/homeassistant/components/broadlink/translations/nl.json b/homeassistant/components/broadlink/translations/nl.json index 7205512d368..d2db5476555 100644 --- a/homeassistant/components/broadlink/translations/nl.json +++ b/homeassistant/components/broadlink/translations/nl.json @@ -2,12 +2,15 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", "cannot_connect": "Kon niet verbinden", + "invalid_host": "Ongeldige hostnaam of IP-adres", "not_supported": "Apparaat wordt niet ondersteund", "unknown": "Onverwachte fout" }, "error": { "cannot_connect": "Kon niet verbinden", + "invalid_host": "Ongeldige hostnaam of IP-adres", "unknown": "Onverwachte fout" }, "flow_title": "{name} ({model} bij {host})", @@ -15,6 +18,15 @@ "finish": { "data": { "name": "Naam" + }, + "title": "Kies een naam voor het apparaat" + }, + "reset": { + "title": "Ontgrendel het apparaat" + }, + "unlock": { + "data": { + "unlock": "Ja, doe het." } }, "user": { diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 6a9d2ca6746..49f1c0ed1a3 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -97,7 +97,6 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(self.brother.serial.lower()) self._abort_if_unique_id_configured() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { "title_placeholders": { @@ -112,7 +111,6 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initiated by zeroconf.""" if user_input is not None: title = f"{self.brother.model} {self.brother.serial}" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 return self.async_create_entry( title=title, data={CONF_HOST: self.host, CONF_TYPE: user_input[CONF_TYPE]}, diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 5ae459c79aa..07843b0f3d0 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -1,5 +1,5 @@ """Constants for Brother integration.""" -from homeassistant.const import PERCENTAGE +from homeassistant.const import ATTR_ICON, PERCENTAGE ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" ATTR_BLACK_DRUM_COUNTER = "black_drum_counter" @@ -20,7 +20,6 @@ ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter" ATTR_ENABLED = "enabled" ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" -ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter" diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 3f275338949..15828e5f05a 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.21"], + "requirements": ["brother==0.2.1"], "zeroconf": [{ "type": "_printer._tcp.local.", "name": "brother*" }], "config_flow": true, "quality_scale": "platinum" diff --git a/homeassistant/components/brother/translations/et.json b/homeassistant/components/brother/translations/et.json index 190db6ed768..7b2b7c1b4a5 100644 --- a/homeassistant/components/brother/translations/et.json +++ b/homeassistant/components/brother/translations/et.json @@ -16,7 +16,7 @@ "host": "Host", "type": "Printeri t\u00fc\u00fcp" }, - "description": "Seadistage Brotheri printeri sidumine. Kui teil on seadistamisega probleeme minge aadressile https://www.home-assistant.io/integrations/brother" + "description": "Seadista Brotheri printeri sidumine. Kui seadistamisega on probleeme mine aadressile https://www.home-assistant.io/integrations/brother" }, "zeroconf_confirm": { "data": { diff --git a/homeassistant/components/brother/translations/ko.json b/homeassistant/components/brother/translations/ko.json index a54aea7f108..47722afdae5 100644 --- a/homeassistant/components/brother/translations/ko.json +++ b/homeassistant/components/brother/translations/ko.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured": "\uc774 \ud504\ub9b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unsupported_model": "\uc774 \ud504\ub9b0\ud130 \ubaa8\ub378\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "snmp_error": "SNMP \uc11c\ubc84\uac00 \uaebc\uc838 \uc788\uac70\ub098 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ud504\ub9b0\ud130\uc785\ub2c8\ub2e4.", "wrong_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml index 460def22dc1..1014e50db21 100644 --- a/homeassistant/components/browser/services.yaml +++ b/homeassistant/components/browser/services.yaml @@ -1,6 +1,12 @@ browse_url: - description: Open a URL in the default browser on the host machine of Home Assistant. + name: Browse + description: + Open a URL in the default browser on the host machine of Home Assistant. fields: url: + name: URL description: The URL to open. + required: true example: "https://www.home-assistant.io" + selector: + text: diff --git a/homeassistant/components/bsblan/translations/ko.json b/homeassistant/components/bsblan/translations/ko.json index 41b421ff817..85703c7eeb7 100644 --- a/homeassistant/components/bsblan/translations/ko.json +++ b/homeassistant/components/bsblan/translations/ko.json @@ -3,13 +3,18 @@ "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "flow_title": "BSB-Lan: {name}", "step": { "user": { "data": { "host": "\ud638\uc2a4\ud2b8", "passkey": "\ud328\uc2a4\ud0a4 \ubb38\uc790\uc5f4", - "port": "\ud3ec\ud2b8" + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, "description": "Home Assistant \uc5d0 BSB-Lan \uae30\uae30 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4.", "title": "BSB-Lan \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uae30" diff --git a/homeassistant/components/bsblan/translations/nl.json b/homeassistant/components/bsblan/translations/nl.json index 850f942df2e..415cd759a8a 100644 --- a/homeassistant/components/bsblan/translations/nl.json +++ b/homeassistant/components/bsblan/translations/nl.json @@ -12,7 +12,9 @@ "data": { "host": "Host", "passkey": "Passkey-tekenreeks", - "port": "Poort" + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" }, "description": "Stel uw BSB-Lan-apparaat in om te integreren met Home Assistant.", "title": "Maak verbinding met het BSB-Lan-apparaat" diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 383f724decd..107eb5598d9 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -1,4 +1,5 @@ """Support for BT Smart Hub (Sometimes referred to as BT Home Hub 6).""" +from collections import namedtuple import logging from btsmarthub_devicelist import BTSmartHub @@ -31,19 +32,30 @@ def get_scanner(hass, config): smarthub_client = BTSmartHub( router_ip=info[CONF_HOST], smarthub_model=info.get(CONF_SMARTHUB_MODEL) ) - scanner = BTSmartHubScanner(smarthub_client) - return scanner if scanner.success_init else None +def _create_device(data): + """Create new device from the dict.""" + ip_address = data.get("IPAddress") + mac = data.get("PhysAddress") + host = data.get("UserHostName") + status = data.get("Active") + name = data.get("name") + return _Device(ip_address, mac, host, status, name) + + +_Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"]) + + class BTSmartHubScanner(DeviceScanner): """This class queries a BT Smart Hub.""" def __init__(self, smarthub_client): """Initialise the scanner.""" self.smarthub = smarthub_client - self.last_results = {} + self.last_results = [] self.success_init = False # Test the router is accessible @@ -56,15 +68,15 @@ class BTSmartHubScanner(DeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client["mac"] for client in self.last_results] + return [device.mac for device in self.last_results] def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if not self.last_results: return None - for client in self.last_results: - if client["mac"] == device: - return client["host"] + for result_device in self.last_results: + if result_device.mac == device: + return result_device.name or result_device.host return None def _update_info(self): @@ -77,26 +89,10 @@ class BTSmartHubScanner(DeviceScanner): if not data: _LOGGER.warning("Error scanning devices") return - - clients = list(data.values()) - self.last_results = clients + self.last_results = data def get_bt_smarthub_data(self): """Retrieve data from BT Smart Hub and return parsed result.""" - # Request data from bt smarthub into a list of dicts. data = self.smarthub.get_devicelist(only_active_devices=True) - - # Renaming keys from parsed result. - devices = {} - for device in data: - try: - devices[device["UserHostName"]] = { - "ip": device["IPAddress"], - "mac": device["PhysAddress"], - "host": device["UserHostName"], - "status": device["Active"], - } - except KeyError: - pass - return devices + return [_create_device(d) for d in data if d.get("PhysAddress")] diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 7abb0ad8444..66b3c974306 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -173,6 +173,8 @@ class WebDavCalendarData: event_list = [] for event in vevent_list: vevent = event.instance.vevent + if not self.is_matching(vevent, self.search): + continue uid = None if hasattr(vevent, "uid"): uid = vevent.uid.value diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 25505800709..99b5cebc2a3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -23,16 +23,8 @@ from homeassistant.components.media_player.const import ( DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.components.stream import request_stream -from homeassistant.components.stream.const import ( - CONF_DURATION, - CONF_LOOKBACK, - CONF_STREAM_SOURCE, - DOMAIN as DOMAIN_STREAM, - FORMAT_CONTENT_TYPE, - OUTPUT_FORMATS, - SERVICE_RECORD, -) +from homeassistant.components.stream import Stream, create_stream +from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, OUTPUT_FORMATS from homeassistant.const import ( ATTR_ENTITY_ID, CONF_FILENAME, @@ -53,7 +45,15 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.loader import bind_hass -from .const import DATA_CAMERA_PREFS, DOMAIN +from .const import ( + CAMERA_IMAGE_TIMEOUT, + CAMERA_STREAM_SOURCE_TIMEOUT, + CONF_DURATION, + CONF_LOOKBACK, + DATA_CAMERA_PREFS, + DOMAIN, + SERVICE_RECORD, +) from .prefs import CameraPreferences # mypy: allow-untyped-calls, allow-untyped-defs @@ -130,23 +130,7 @@ class Image: async def async_request_stream(hass, entity_id, fmt): """Request a stream for a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) - camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - - async with async_timeout.timeout(10): - source = await camera.stream_source() - - if not source: - raise HomeAssistantError( - f"{camera.entity_id} does not support play stream service" - ) - - return request_stream( - hass, - source, - fmt=fmt, - keepalive=camera_prefs.preload_stream, - options=camera.stream_options, - ) + return await _async_stream_endpoint_url(hass, camera, fmt) @bind_hass @@ -267,14 +251,12 @@ async def async_setup(hass, config): camera_prefs = prefs.get(camera.entity_id) if not camera_prefs.preload_stream: continue - - async with async_timeout.timeout(10): - source = await camera.stream_source() - - if not source: + stream = await camera.create_stream() + if not stream: continue - - request_stream(hass, source, keepalive=True, options=camera.stream_options) + stream.keepalive = True + stream.add_provider("hls") + stream.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) @@ -330,6 +312,7 @@ class Camera(Entity): def __init__(self): """Initialize a camera.""" self.is_streaming = False + self.stream = None self.stream_options = {} self.content_type = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) @@ -375,6 +358,17 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return 0.5 + async def create_stream(self) -> Stream: + """Create a Stream for stream_source.""" + # There is at most one stream (a decode worker) per camera + if not self.stream: + async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): + source = await self.stream_source() + if not source: + return None + self.stream = create_stream(self.hass, source, options=self.stream_options) + return self.stream + async def stream_source(self): """Return the source of the stream.""" return None @@ -515,7 +509,7 @@ class CameraImageView(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.Response: """Serve camera image.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with async_timeout.timeout(CAMERA_IMAGE_TIMEOUT): image = await camera.async_camera_image() if image: @@ -586,24 +580,7 @@ async def ws_camera_stream(hass, connection, msg): try: entity_id = msg["entity_id"] camera = _get_camera_from_entity_id(hass, entity_id) - camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - - async with async_timeout.timeout(10): - source = await camera.stream_source() - - if not source: - raise HomeAssistantError( - f"{camera.entity_id} does not support play stream service" - ) - - fmt = msg["format"] - url = request_stream( - hass, - source, - fmt=fmt, - keepalive=camera_prefs.preload_stream, - options=camera.stream_options, - ) + url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"]) connection.send_result(msg["id"], {"url": url}) except HomeAssistantError as ex: _LOGGER.error("Error requesting stream: %s", ex) @@ -676,32 +653,17 @@ async def async_handle_snapshot_service(camera, service): async def async_handle_play_stream_service(camera, service_call): """Handle play stream services calls.""" - async with async_timeout.timeout(10): - source = await camera.stream_source() - - if not source: - raise HomeAssistantError( - f"{camera.entity_id} does not support play stream service" - ) + fmt = service_call.data[ATTR_FORMAT] + url = await _async_stream_endpoint_url(camera.hass, camera, fmt) hass = camera.hass - camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) - fmt = service_call.data[ATTR_FORMAT] - entity_ids = service_call.data[ATTR_MEDIA_PLAYER] - - url = request_stream( - hass, - source, - fmt=fmt, - keepalive=camera_prefs.preload_stream, - options=camera.stream_options, - ) data = { ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}", ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], } # It is required to send a different payload for cast media players + entity_ids = service_call.data[ATTR_MEDIA_PLAYER] cast_entity_ids = [ entity for entity, source in entity_sources(hass).items() @@ -740,12 +702,27 @@ async def async_handle_play_stream_service(camera, service_call): ) +async def _async_stream_endpoint_url(hass, camera, fmt): + stream = await camera.create_stream() + if not stream: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + # Update keepalive setting which manages idle shutdown + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) + stream.keepalive = camera_prefs.preload_stream + + stream.add_provider(fmt) + stream.start() + return stream.endpoint_url(fmt) + + async def async_handle_record_service(camera, call): """Handle stream recording service calls.""" - async with async_timeout.timeout(10): - source = await camera.stream_source() + stream = await camera.create_stream() - if not source: + if not stream: raise HomeAssistantError(f"{camera.entity_id} does not support record service") hass = camera.hass @@ -753,13 +730,6 @@ async def async_handle_record_service(camera, call): filename.hass = hass video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera}) - data = { - CONF_STREAM_SOURCE: source, - CONF_FILENAME: video_path, - CONF_DURATION: call.data[CONF_DURATION], - CONF_LOOKBACK: call.data[CONF_LOOKBACK], - } - - await hass.services.async_call( - DOMAIN_STREAM, SERVICE_RECORD, data, blocking=True, context=call.context + await stream.async_record( + video_path, duration=call.data[CONF_DURATION], lookback=call.data[CONF_LOOKBACK] ) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 563f0554f0f..7218b19f8fe 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -4,3 +4,11 @@ DOMAIN = "camera" DATA_CAMERA_PREFS = "camera_prefs" PREF_PRELOAD_STREAM = "preload_stream" + +SERVICE_RECORD = "record" + +CONF_LOOKBACK = "lookback" +CONF_DURATION = "duration" + +CAMERA_STREAM_SOURCE_TIMEOUT = 10 +CAMERA_IMAGE_TIMEOUT = 10 diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 70d33da884c..3c8e99f001b 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,69 +1,96 @@ # Describes the format for available camera services turn_off: + name: Turn off description: Turn off camera. - fields: - entity_id: - description: Entity id. - example: "camera.living_room" + target: turn_on: + name: Turn on description: Turn on camera. - fields: - entity_id: - description: Entity id. - example: "camera.living_room" + target: enable_motion_detection: + name: Enable motion detection description: Enable the motion detection in a camera. - fields: - entity_id: - description: Name(s) of entities to enable motion detection. - example: "camera.living_room_camera" + target: disable_motion_detection: + name: Disable motion detection description: Disable the motion detection in a camera. - fields: - entity_id: - description: Name(s) of entities to disable motion detection. - example: "camera.living_room_camera" + target: snapshot: + name: Take snapshot description: Take a snapshot from a camera. + target: fields: - entity_id: - description: Name(s) of entities to create snapshots from. - example: "camera.living_room_camera" filename: + name: Filename description: Template of a Filename. Variable is entity_id. + required: true example: "/tmp/snapshot_{{ entity_id.name }}.jpg" + selector: + text: play_stream: + name: Play stream description: Play camera stream on supported media player. + target: fields: - entity_id: - description: Name(s) of entities to stream from. - example: "camera.living_room_camera" media_player: + name: Media Player description: Name(s) of media player to stream to. + required: true example: "media_player.living_room_tv" + selector: + entity: + domain: media_player format: - description: (Optional) Stream format supported by media player. + name: Format + description: Stream format supported by media player. + default: "hls" example: "hls" + selector: + select: + options: + - "hls" record: + name: Record description: Record live camera feed. + target: fields: - entity_id: - description: Name of entities to record. - example: "camera.living_room_camera" filename: - description: Template of a Filename. Variable is entity_id. Must be mp4. + name: Filename + description: Template of a Filename. Variable is entity_id. Must be mp4. + required: true example: "/tmp/snapshot_{{ entity_id.name }}.mp4" + selector: + text: duration: - description: (Optional) Target recording length (in seconds). + name: Duration + description: Target recording length. default: 30 example: 30 + selector: + number: + min: 1 + max: 3600 + step: 1 + unit_of_measurement: seconds + mode: slider lookback: - description: (Optional) Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream. + name: Lookback + description: + Target lookback period to include in addition to duration. Only + available if there is currently an active HLS stream. + default: 0 example: 4 + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider diff --git a/homeassistant/components/canary/translations/ko.json b/homeassistant/components/canary/translations/ko.json index 0b1d82bb20a..d02344a9027 100644 --- a/homeassistant/components/canary/translations/ko.json +++ b/homeassistant/components/canary/translations/ko.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "\uc774\ubbf8 \uc124\uc815\ub418\uc5b4 \uc788\uc74c. \ud558\ub098\uc758 \uc124\uc815\ub9cc \uac00\ub2a5\ud568.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec \ubc1c\uc0dd" + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Canary: {name}", "step": { "user": { "data": { - "password": "\uc554\ud638", - "username": "\uc0ac\uc6a9\uc790\uba85" + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, "title": "Canary\uc5d0 \uc5f0\uacb0" } diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index e7db380406b..b8742ec2b5e 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,4 +1,6 @@ """Helpers to deal with Cast devices.""" +from __future__ import annotations + from typing import Optional import attr @@ -57,7 +59,7 @@ class ChromecastInfo: return None return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") - def fill_out_missing_chromecast_info(self) -> "ChromecastInfo": + def fill_out_missing_chromecast_info(self) -> ChromecastInfo: """Return a new ChromecastInfo object with missing attributes filled in. Uses blocking HTTP / HTTPS. diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 88dabc8d04d..28ccb78d5b9 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==8.0.0"], + "requirements": ["pychromecast==8.1.2"], "after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"], "zeroconf": ["_googlecast._tcp.local."], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 6bedae1cac5..981d67f0caa 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -10,6 +10,7 @@ import pychromecast from pychromecast.controllers.homeassistant import HomeAssistantController from pychromecast.controllers.multizone import MultizoneManager from pychromecast.controllers.plex import PlexController +from pychromecast.controllers.receiver import VOLUME_CONTROL_TYPE_FIXED from pychromecast.quick_play import quick_play from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, @@ -82,8 +83,6 @@ SUPPORT_CAST = ( | SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON - | SUPPORT_VOLUME_MUTE - | SUPPORT_VOLUME_SET ) @@ -743,6 +742,10 @@ class CastDevice(MediaPlayerEntity): support = SUPPORT_CAST media_status = self._media_status()[0] + if self.cast_status: + if self.cast_status.volume_control_type != VOLUME_CONTROL_TYPE_FIXED: + support |= SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET + if media_status: if media_status.supports_queue_next: support |= SUPPORT_PREVIOUS_TRACK diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index d1c29281aad..8e4466c349c 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -5,7 +5,7 @@ show_lovelace_view: description: Media Player entity to show the Lovelace view on. example: "media_player.kitchen" dashboard_path: - description: The url path of the Lovelace dashboard to show. + description: The URL path of the Lovelace dashboard to show. example: lovelace-cast view_path: description: The path of the Lovelace view to show. diff --git a/homeassistant/components/cast/translations/ko.json b/homeassistant/components/cast/translations/ko.json index e57fceb7705..7011a61f757 100644 --- a/homeassistant/components/cast/translations/ko.json +++ b/homeassistant/components/cast/translations/ko.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "no_devices_found": "Google \uce90\uc2a4\ud2b8 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Google \uce90\uc2a4\ud2b8\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { - "description": "Google \uce90\uc2a4\ud2b8\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" } } } diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index 7953a7bb8cf..282c87b25c5 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -63,9 +63,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=title, data={CONF_HOST: host, CONF_PORT: port}, ) - if ( # pylint: disable=no-member - self.context["source"] == config_entries.SOURCE_IMPORT - ): + if self.context["source"] == config_entries.SOURCE_IMPORT: _LOGGER.error("Config import failed for %s", user_input[CONF_HOST]) return self.async_abort(reason="import_failed") else: diff --git a/homeassistant/components/cert_expiry/translations/ko.json b/homeassistant/components/cert_expiry/translations/ko.json index ee912a33695..87827769771 100644 --- a/homeassistant/components/cert_expiry/translations/ko.json +++ b/homeassistant/components/cert_expiry/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "import_failed": "\uad6c\uc131\uc5d0\uc11c \uac00\uc838\uc624\uae30 \uc2e4\ud328" }, "error": { diff --git a/homeassistant/components/cert_expiry/translations/sv.json b/homeassistant/components/cert_expiry/translations/sv.json index 23703f11e5b..f00fc236d09 100644 --- a/homeassistant/components/cert_expiry/translations/sv.json +++ b/homeassistant/components/cert_expiry/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Tj\u00e4nsten har redan konfigurerats" + }, "error": { "connection_refused": "Anslutningen blev tillbakavisad under anslutning till v\u00e4rd.", "connection_timeout": "Timeout vid anslutning till den h\u00e4r v\u00e4rden", diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index 481cdd7ecad..5376dc3fe97 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, ) from homeassistant.const import ( + ATTR_SECONDS, CONF_HOST, CONF_NAME, CONF_PORT, @@ -53,10 +54,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -# Service call validation schemas -ATTR_SECONDS = "seconds" - - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Channels platform.""" device = ChannelsPlayer(config[CONF_NAME], config[CONF_HOST], config[CONF_PORT]) diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py new file mode 100644 index 00000000000..b6e70ab56e8 --- /dev/null +++ b/homeassistant/components/climacell/__init__.py @@ -0,0 +1,263 @@ +"""The ClimaCell integration.""" +import asyncio +from datetime import timedelta +import logging +from math import ceil +from typing import Any, Dict, Optional, Union + +from pyclimacell import ClimaCell +from pyclimacell.const import ( + FORECAST_DAILY, + FORECAST_HOURLY, + FORECAST_NOWCAST, + REALTIME, +) +from pyclimacell.pyclimacell import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ATTRIBUTION, + CONF_TIMESTEP, + CURRENT, + DAILY, + DEFAULT_TIMESTEP, + DOMAIN, + FORECASTS, + HOURLY, + MAX_REQUESTS_PER_DAY, + NOWCAST, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [WEATHER_DOMAIN] + + +def _set_update_interval( + hass: HomeAssistantType, current_entry: ConfigEntry +) -> timedelta: + """Recalculate update_interval based on existing ClimaCell instances and update them.""" + # We check how many ClimaCell configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # MAX_REQUESTS_PER_DAY by 4 because every update requires four API calls and we want + # a buffer in the number of API calls left at the end of the day. + other_instance_entry_ids = [ + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id != current_entry.entry_id + and entry.data[CONF_API_KEY] == current_entry.data[CONF_API_KEY] + ] + + interval = timedelta( + minutes=( + ceil( + (24 * 60 * (len(other_instance_entry_ids) + 1) * 4) + / (MAX_REQUESTS_PER_DAY * 0.9) + ) + ) + ) + + for entry_id in other_instance_entry_ids: + if entry_id in hass.data[DOMAIN]: + hass.data[DOMAIN][entry_id].update_interval = interval + + return interval + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the ClimaCell API component.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up ClimaCell API from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # If config entry options not set up, set them up + if not config_entry.options: + hass.config_entries.async_update_entry( + config_entry, + options={ + CONF_TIMESTEP: DEFAULT_TIMESTEP, + }, + ) + + coordinator = ClimaCellDataUpdateCoordinator( + hass, + config_entry, + ClimaCell( + config_entry.data[CONF_API_KEY], + config_entry.data.get(CONF_LATITUDE, hass.config.latitude), + config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + session=async_get_clientsession(hass), + ), + _set_update_interval(hass, config_entry), + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + +class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold ClimaCell data.""" + + def __init__( + self, + hass: HomeAssistantType, + config_entry: ConfigEntry, + api: ClimaCell, + update_interval: timedelta, + ) -> None: + """Initialize.""" + + self._config_entry = config_entry + self._api = api + self.name = config_entry.data[CONF_NAME] + self.data = {CURRENT: {}, FORECASTS: {}} + + super().__init__( + hass, + _LOGGER, + name=config_entry.data[CONF_NAME], + update_interval=update_interval, + ) + + async def _async_update_data(self) -> Dict[str, Any]: + """Update data via library.""" + data = {FORECASTS: {}} + try: + data[CURRENT] = await self._api.realtime( + self._api.available_fields(REALTIME) + ) + data[FORECASTS][HOURLY] = await self._api.forecast_hourly( + self._api.available_fields(FORECAST_HOURLY), + None, + timedelta(hours=24), + ) + + data[FORECASTS][DAILY] = await self._api.forecast_daily( + self._api.available_fields(FORECAST_DAILY), None, timedelta(days=14) + ) + + data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast( + self._api.available_fields(FORECAST_NOWCAST), + None, + timedelta( + minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30) + ), + self._config_entry.options[CONF_TIMESTEP], + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + return data + + +class ClimaCellEntity(CoordinatorEntity): + """Base ClimaCell Entity.""" + + def __init__( + self, config_entry: ConfigEntry, coordinator: ClimaCellDataUpdateCoordinator + ) -> None: + """Initialize ClimaCell Entity.""" + super().__init__(coordinator) + self._config_entry = config_entry + + @staticmethod + def _get_cc_value( + weather_dict: Dict[str, Any], key: str + ) -> Optional[Union[int, float, str]]: + """Return property from weather_dict.""" + items = weather_dict.get(key, {}) + # Handle cases where value returned is a list. + # Optimistically find the best value to return. + if isinstance(items, list): + if len(items) == 1: + return items[0].get("value") + return next( + (item.get("value") for item in items if "max" in item), + next( + (item.get("value") for item in items if "min" in item), + items[0].get("value", None), + ), + ) + + return items.get("value") + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._config_entry.data[CONF_NAME] + + @property + def unique_id(self) -> str: + """Return the unique id of the entity.""" + return self._config_entry.unique_id + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def device_info(self) -> Dict[str, Any]: + """Return device registry information.""" + return { + "identifiers": {(DOMAIN, self._config_entry.data[CONF_API_KEY])}, + "name": "ClimaCell", + "manufacturer": "ClimaCell", + "sw_version": "v3", + "entry_type": "service", + } diff --git a/homeassistant/components/climacell/config_flow.py b/homeassistant/components/climacell/config_flow.py new file mode 100644 index 00000000000..09e02f3f559 --- /dev/null +++ b/homeassistant/components/climacell/config_flow.py @@ -0,0 +1,146 @@ +"""Config flow for ClimaCell integration.""" +import logging +from typing import Any, Dict + +from pyclimacell import ClimaCell +from pyclimacell.const import REALTIME +from pyclimacell.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import CONF_TIMESTEP, DEFAULT_NAME, DEFAULT_TIMESTEP +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +def _get_config_schema( + hass: core.HomeAssistant, input_dict: Dict[str, Any] = None +) -> vol.Schema: + """ + Return schema defaults for init step based on user input/config dict. + + Retain info already provided for future form views by setting them as + defaults in schema. + """ + if input_dict is None: + input_dict = {} + + return vol.Schema( + { + vol.Required( + CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, + vol.Inclusive( + CONF_LATITUDE, + "location", + default=input_dict.get(CONF_LATITUDE, hass.config.latitude), + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, + "location", + default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), + ): cv.longitude, + }, + extra=vol.REMOVE_EXTRA, + ) + + +def _get_unique_id(hass: HomeAssistantType, input_dict: Dict[str, Any]): + """Return unique ID from config data.""" + return ( + f"{input_dict[CONF_API_KEY]}" + f"_{input_dict.get(CONF_LATITUDE, hass.config.latitude)}" + f"_{input_dict.get(CONF_LONGITUDE, hass.config.longitude)}" + ) + + +class ClimaCellOptionsConfigFlow(config_entries.OptionsFlow): + """Handle ClimaCell options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize ClimaCell options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Manage the ClimaCell options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options_schema = { + vol.Required( + CONF_TIMESTEP, + default=self._config_entry.options.get(CONF_TIMESTEP, DEFAULT_TIMESTEP), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)), + } + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(options_schema) + ) + + +class ClimaCellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for ClimaCell Weather API.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ClimaCellOptionsConfigFlow: + """Get the options flow for this handler.""" + return ClimaCellOptionsConfigFlow(config_entry) + + async def async_step_user( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle the initial step.""" + assert self.hass + errors = {} + if user_input is not None: + await self.async_set_unique_id( + unique_id=_get_unique_id(self.hass, user_input) + ) + self._abort_if_unique_id_configured() + + try: + await ClimaCell( + user_input[CONF_API_KEY], + str(user_input.get(CONF_LATITUDE, self.hass.config.latitude)), + str(user_input.get(CONF_LONGITUDE, self.hass.config.longitude)), + session=async_get_clientsession(self.hass), + ).realtime(ClimaCell.first_field(REALTIME)) + + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + except CantConnectException: + errors["base"] = "cannot_connect" + except InvalidAPIKeyException: + errors[CONF_API_KEY] = "invalid_api_key" + except RateLimitedException: + errors[CONF_API_KEY] = "rate_limited" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", + data_schema=_get_config_schema(self.hass, user_input), + errors=errors, + ) diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py new file mode 100644 index 00000000000..f2d0a596121 --- /dev/null +++ b/homeassistant/components/climacell/const.py @@ -0,0 +1,81 @@ +"""Constants for the ClimaCell integration.""" + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) + +CONF_TIMESTEP = "timestep" + +DAILY = "daily" +HOURLY = "hourly" +NOWCAST = "nowcast" +FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] + +CURRENT = "current" +FORECASTS = "forecasts" + +DEFAULT_NAME = "ClimaCell" +DEFAULT_TIMESTEP = 15 +DEFAULT_FORECAST_TYPE = DAILY +DOMAIN = "climacell" +ATTRIBUTION = "Powered by ClimaCell" + +MAX_REQUESTS_PER_DAY = 1000 + +CONDITIONS = { + "breezy": ATTR_CONDITION_WINDY, + "freezing_rain_heavy": ATTR_CONDITION_SNOWY_RAINY, + "freezing_rain": ATTR_CONDITION_SNOWY_RAINY, + "freezing_rain_light": ATTR_CONDITION_SNOWY_RAINY, + "freezing_drizzle": ATTR_CONDITION_SNOWY_RAINY, + "ice_pellets_heavy": ATTR_CONDITION_HAIL, + "ice_pellets": ATTR_CONDITION_HAIL, + "ice_pellets_light": ATTR_CONDITION_HAIL, + "snow_heavy": ATTR_CONDITION_SNOWY, + "snow": ATTR_CONDITION_SNOWY, + "snow_light": ATTR_CONDITION_SNOWY, + "flurries": ATTR_CONDITION_SNOWY, + "tstorm": ATTR_CONDITION_LIGHTNING, + "rain_heavy": ATTR_CONDITION_POURING, + "rain": ATTR_CONDITION_RAINY, + "rain_light": ATTR_CONDITION_RAINY, + "drizzle": ATTR_CONDITION_RAINY, + "fog_light": ATTR_CONDITION_FOG, + "fog": ATTR_CONDITION_FOG, + "cloudy": ATTR_CONDITION_CLOUDY, + "mostly_cloudy": ATTR_CONDITION_CLOUDY, + "partly_cloudy": ATTR_CONDITION_PARTLYCLOUDY, +} + +CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} + +CC_ATTR_TIMESTAMP = "observation_time" +CC_ATTR_TEMPERATURE = "temp" +CC_ATTR_TEMPERATURE_HIGH = "max" +CC_ATTR_TEMPERATURE_LOW = "min" +CC_ATTR_PRESSURE = "baro_pressure" +CC_ATTR_HUMIDITY = "humidity" +CC_ATTR_WIND_SPEED = "wind_speed" +CC_ATTR_WIND_DIRECTION = "wind_direction" +CC_ATTR_OZONE = "o3" +CC_ATTR_CONDITION = "weather_code" +CC_ATTR_VISIBILITY = "visibility" +CC_ATTR_PRECIPITATION = "precipitation" +CC_ATTR_PRECIPITATION_DAILY = "precipitation_accumulation" +CC_ATTR_PRECIPITATION_PROBABILITY = "precipitation_probability" +CC_ATTR_PM_2_5 = "pm25" +CC_ATTR_PM_10 = "pm10" +CC_ATTR_CARBON_MONOXIDE = "co" +CC_ATTR_SULPHUR_DIOXIDE = "so2" +CC_ATTR_NITROGEN_DIOXIDE = "no2" diff --git a/homeassistant/components/climacell/manifest.json b/homeassistant/components/climacell/manifest.json new file mode 100644 index 00000000000..f410c2275a9 --- /dev/null +++ b/homeassistant/components/climacell/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "climacell", + "name": "ClimaCell", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/climacell", + "requirements": ["pyclimacell==0.14.0"], + "codeowners": ["@raman325"] +} diff --git a/homeassistant/components/climacell/strings.json b/homeassistant/components/climacell/strings.json new file mode 100644 index 00000000000..be80ac4e506 --- /dev/null +++ b/homeassistant/components/climacell/strings.json @@ -0,0 +1,34 @@ +{ + "title": "ClimaCell", + "config": { + "step": { + "user": { + "description": "If [%key:common::config_flow::data::latitude%] and [%key:common::config_flow::data::longitude%] are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "rate_limited": "Currently rate limited, please try again later." + } + }, + "options": { + "step": { + "init": { + "title": "Update [%key:component::climacell::title%] Options", + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "data": { + "timestep": "Min. Between NowCast Forecasts", + "forecast_types": "Forecast Type(s)" + } + } + } + } +} diff --git a/homeassistant/components/climacell/translations/af.json b/homeassistant/components/climacell/translations/af.json new file mode 100644 index 00000000000..b62fc7023a4 --- /dev/null +++ b/homeassistant/components/climacell/translations/af.json @@ -0,0 +1,10 @@ +{ + "options": { + "step": { + "init": { + "title": "Update ClimaCell opties" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/ca.json b/homeassistant/components/climacell/translations/ca.json new file mode 100644 index 00000000000..23afb6a3d90 --- /dev/null +++ b/homeassistant/components/climacell/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida", + "rate_limited": "Freq\u00fc\u00e8ncia limitada temporalment, torna-ho a provar m\u00e9s tard.", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom" + }, + "description": "Si no es proporcionen la Latitud i Longitud, s'utilitzaran els valors per defecte de la configuraci\u00f3 de Home Assistant. Es crear\u00e0 una entitat per a cada tipus de previsi\u00f3, per\u00f2 nom\u00e9s s'habilitaran les que seleccionis." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Tipus de previsi\u00f3", + "timestep": "Minuts entre previsions NowCast" + }, + "description": "Si decideixes activar l'entitat de predicci\u00f3 \"nowcast\", podr\u00e0s configurar l'interval en minuts entre cada previsi\u00f3. El nombre de previsions proporcionades dep\u00e8n d'aquest interval de minuts.", + "title": "Actualitzaci\u00f3 de les opcions de ClimaCell" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/cs.json b/homeassistant/components/climacell/translations/cs.json new file mode 100644 index 00000000000..1ae29deb08c --- /dev/null +++ b/homeassistant/components/climacell/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "Jm\u00e9no" + } + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/de.json b/homeassistant/components/climacell/translations/de.json new file mode 100644 index 00000000000..f18197e1cca --- /dev/null +++ b/homeassistant/components/climacell/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/en.json b/homeassistant/components/climacell/translations/en.json new file mode 100644 index 00000000000..ed3ead421e1 --- /dev/null +++ b/homeassistant/components/climacell/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "rate_limited": "Currently rate limited, please try again later.", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "If Latitude and Longitude are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Forecast Type(s)", + "timestep": "Min. Between NowCast Forecasts" + }, + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "title": "Update ClimaCell Options" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/es.json b/homeassistant/components/climacell/translations/es.json new file mode 100644 index 00000000000..4c4d8fcc9bb --- /dev/null +++ b/homeassistant/components/climacell/translations/es.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "rate_limited": "Actualmente la tarifa est\u00e1 limitada, por favor int\u00e9ntelo m\u00e1s tarde." + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, + "description": "Si no se proporcionan Latitud y Longitud , se utilizar\u00e1n los valores predeterminados en la configuraci\u00f3n de Home Assistant. Se crear\u00e1 una entidad para cada tipo de pron\u00f3stico, pero solo las que seleccione estar\u00e1n habilitadas de forma predeterminada." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Tipo(s) de pron\u00f3stico", + "timestep": "Min. Entre pron\u00f3sticos de NowCast" + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos.", + "title": "Actualizar las opciones ClimaCell" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/et.json b/homeassistant/components/climacell/translations/et.json new file mode 100644 index 00000000000..3722c258afa --- /dev/null +++ b/homeassistant/components/climacell/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_api_key": "Vale API v\u00f5ti", + "rate_limited": "Hetkel on p\u00e4ringud piiratud, proovi hiljem uuesti.", + "unknown": "Tundmatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti", + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad", + "name": "Nimi" + }, + "description": "Kui [%key:component::climacell::config::step::user::d ata::latitude%] ja [%key:component::climacell::config::step::user::d ata::longitude%] andmed pole sisestatud kasutatakse Home Assistanti vaikev\u00e4\u00e4rtusi. Olem luuakse iga prognoosit\u00fc\u00fcbi jaoks kuid vaikimisi lubatakse ainult need, mille valid." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Prognoosi t\u00fc\u00fcp (t\u00fc\u00fcbid)", + "timestep": "Minuteid NowCasti prognooside vahel" + }, + "description": "Kui otsustad lubada \"nowcast\" prognoosi\u00fcksuse, saad seadistada minutite arvu iga prognoosi vahel. Esitatavate prognooside arv s\u00f5ltub prognooside vahel valitud minutite arvust.", + "title": "V\u00e4rskenda ClimaCell suvandeid" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/fr.json b/homeassistant/components/climacell/translations/fr.json new file mode 100644 index 00000000000..8fd3f7b7122 --- /dev/null +++ b/homeassistant/components/climacell/translations/fr.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API invalide", + "rate_limited": "Currently rate limited, please try again later.", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom" + }, + "description": "Si Latitude et Longitude ne sont pas fournis, les valeurs par d\u00e9faut de la configuration de Home Assistant seront utilis\u00e9es. Une entit\u00e9 sera cr\u00e9\u00e9e pour chaque type de pr\u00e9vision, mais seules celles que vous s\u00e9lectionnez seront activ\u00e9es par d\u00e9faut." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Type(s) de pr\u00e9vision", + "timestep": "Min. Entre les pr\u00e9visions NowCast" + }, + "description": "Si vous choisissez d'activer l'entit\u00e9 de pr\u00e9vision \u00abnowcast\u00bb, vous pouvez configurer le nombre de minutes entre chaque pr\u00e9vision. Le nombre de pr\u00e9visions fournies d\u00e9pend du nombre de minutes choisies entre les pr\u00e9visions.", + "title": "Mettre \u00e0 jour les options de ClimaCell" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/he.json b/homeassistant/components/climacell/translations/he.json new file mode 100644 index 00000000000..81a4b5c1fce --- /dev/null +++ b/homeassistant/components/climacell/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/it.json b/homeassistant/components/climacell/translations/it.json new file mode 100644 index 00000000000..cc7df4f8ab3 --- /dev/null +++ b/homeassistant/components/climacell/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida", + "rate_limited": "Al momento la tariffa \u00e8 limitata, riprova pi\u00f9 tardi.", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome" + }, + "description": "Se Latitudine e Logitudine non vengono forniti, verranno utilizzati i valori predefiniti nella configurazione di Home Assistant. Verr\u00e0 creata un'entit\u00e0 per ogni tipo di previsione, ma solo quelli selezionati saranno abilitati per impostazione predefinita." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Tipo(i) di previsione", + "timestep": "Minuti tra le previsioni di NowCast" + }, + "description": "Se scegli di abilitare l'entit\u00e0 di previsione `nowcast`, puoi configurare il numero di minuti tra ogni previsione. Il numero di previsioni fornite dipende dal numero di minuti scelti tra le previsioni.", + "title": "Aggiorna le opzioni di ClimaCell" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/nl.json b/homeassistant/components/climacell/translations/nl.json new file mode 100644 index 00000000000..488a43ae24e --- /dev/null +++ b/homeassistant/components/climacell/translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_api_key": "Ongeldige API-sleutel", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam" + }, + "description": "Indien Breedtegraad en Lengtegraad niet worden opgegeven, worden de standaardwaarden in de Home Assistant-configuratie gebruikt. Er wordt een entiteit gemaakt voor elk voorspellingstype maar alleen degene die u selecteert worden standaard ingeschakeld." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Voorspellingstype(n)" + }, + "description": "Als u ervoor kiest om de `nowcast` voorspellingsentiteit in te schakelen, kan u het aantal minuten tussen elke voorspelling configureren. Het aantal voorspellingen hangt af van het aantal gekozen minuten tussen de voorspellingen.", + "title": "Update ClimaCell Opties" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/no.json b/homeassistant/components/climacell/translations/no.json new file mode 100644 index 00000000000..d59f5590518 --- /dev/null +++ b/homeassistant/components/climacell/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "rate_limited": "Prisen er for \u00f8yeblikket begrenset. Pr\u00f8v igjen senere.", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "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." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "Prognosetype(r)", + "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.", + "title": "Oppdater ClimaCell Alternativer" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/ru.json b/homeassistant/components/climacell/translations/ru.json new file mode 100644 index 00000000000..2cce63d95ea --- /dev/null +++ b/homeassistant/components/climacell/translations/ru.json @@ -0,0 +1,34 @@ +{ + "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.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "rate_limited": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u0415\u0441\u043b\u0438 \u0428\u0438\u0440\u043e\u0442\u0430 \u0438 \u0414\u043e\u043b\u0433\u043e\u0442\u0430 \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u044b, \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0431\u0443\u0434\u0443\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 Home Assistant. \u041e\u0431\u044a\u0435\u043a\u0442\u044b \u0431\u0443\u0434\u0443\u0442 \u0441\u043e\u0437\u0434\u0430\u043d\u044b \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0442\u0438\u043f\u0430 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430, \u043d\u043e \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0431\u0443\u0434\u0443\u0442 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u0412\u0430\u043c\u0438." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "\u0422\u0438\u043f(\u044b) \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430", + "timestep": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f (\u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445)" + }, + "description": "\u0415\u0441\u043b\u0438 \u0412\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0435\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 'nowcast', \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430.", + "title": "\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 ClimaCell" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/zh-Hant.json b/homeassistant/components/climacell/translations/zh-Hant.json new file mode 100644 index 00000000000..76eaf50b932 --- /dev/null +++ b/homeassistant/components/climacell/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "rate_limited": "\u9054\u5230\u9650\u5236\u983b\u7387\u3001\u8acb\u7a0d\u5019\u518d\u8a66\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u540d\u7a31" + }, + "description": "\u5047\u5982\u672a\u63d0\u4f9b\u7def\u5ea6\u8207\u7d93\u5ea6\uff0c\u5c07\u6703\u4f7f\u7528 Home Assistant \u8a2d\u5b9a\u4f5c\u70ba\u9810\u8a2d\u503c\u3002\u6bcf\u4e00\u500b\u9810\u5831\u985e\u578b\u90fd\u6703\u7522\u751f\u4e00\u7d44\u5be6\u9ad4\uff0c\u6216\u8005\u9810\u8a2d\u70ba\u6240\u9078\u64c7\u555f\u7528\u7684\u9810\u5831\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "forecast_types": "\u9810\u5831\u985e\u578b", + "timestep": "NowCast \u9810\u5831\u9593\u9694\u5206\u9418" + }, + "description": "\u5047\u5982\u9078\u64c7\u958b\u555f `nowcast` \u9810\u5831\u5be6\u9ad4\u3001\u5c07\u53ef\u4ee5\u8a2d\u5b9a\u9810\u5831\u983b\u7387\u9593\u9694\u5206\u9418\u6578\u3002\u6839\u64da\u6240\u8f38\u5165\u7684\u9593\u9694\u6642\u9593\u5c07\u6c7a\u5b9a\u9810\u5831\u7684\u6578\u76ee\u3002", + "title": "\u66f4\u65b0 ClimaCell \u9078\u9805" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py new file mode 100644 index 00000000000..c77bbfbd50a --- /dev/null +++ b/homeassistant/components/climacell/weather.py @@ -0,0 +1,305 @@ +"""Weather component that handles meteorological data for your location.""" +from datetime import datetime +import logging +from typing import Any, Callable, Dict, List, Optional + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + LENGTH_FEET, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRESSURE_HPA, + PRESSURE_INHG, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.sun import is_up +from homeassistant.helpers.typing import HomeAssistantType +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.temperature import convert as temp_convert + +from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity +from .const import ( + CC_ATTR_CONDITION, + CC_ATTR_HUMIDITY, + CC_ATTR_OZONE, + CC_ATTR_PRECIPITATION, + CC_ATTR_PRECIPITATION_DAILY, + CC_ATTR_PRECIPITATION_PROBABILITY, + CC_ATTR_PRESSURE, + CC_ATTR_TEMPERATURE, + CC_ATTR_TEMPERATURE_HIGH, + CC_ATTR_TEMPERATURE_LOW, + CC_ATTR_TIMESTAMP, + CC_ATTR_VISIBILITY, + CC_ATTR_WIND_DIRECTION, + CC_ATTR_WIND_SPEED, + CLEAR_CONDITIONS, + CONDITIONS, + CONF_TIMESTEP, + CURRENT, + DAILY, + DEFAULT_FORECAST_TYPE, + DOMAIN, + FORECASTS, + HOURLY, + NOWCAST, +) + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + + +def _translate_condition( + condition: Optional[str], sun_is_up: bool = True +) -> Optional[str]: + """Translate ClimaCell condition into an HA condition.""" + if not condition: + return None + if "clear" in condition.lower(): + if sun_is_up: + return CLEAR_CONDITIONS["day"] + return CLEAR_CONDITIONS["night"] + return CONDITIONS[condition] + + +def _forecast_dict( + hass: HomeAssistantType, + forecast_dt: datetime, + use_datetime: bool, + condition: str, + precipitation: Optional[float], + precipitation_probability: Optional[float], + temp: Optional[float], + temp_low: Optional[float], + wind_direction: Optional[float], + wind_speed: Optional[float], +) -> Dict[str, Any]: + """Return formatted Forecast dict from ClimaCell forecast data.""" + if use_datetime: + translated_condition = _translate_condition(condition, is_up(hass, forecast_dt)) + else: + translated_condition = _translate_condition(condition, True) + + if hass.config.units.is_metric: + if precipitation: + precipitation = ( + distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) * 1000 + ) + if temp: + temp = temp_convert(temp, TEMP_FAHRENHEIT, TEMP_CELSIUS) + if temp_low: + temp_low = temp_convert(temp_low, TEMP_FAHRENHEIT, TEMP_CELSIUS) + if wind_speed: + wind_speed = distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + + data = { + ATTR_FORECAST_TIME: forecast_dt.isoformat(), + ATTR_FORECAST_CONDITION: translated_condition, + ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, + ATTR_FORECAST_TEMP: temp, + ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_WIND_BEARING: wind_direction, + ATTR_FORECAST_WIND_SPEED: wind_speed, + } + + return {k: v for k, v in data.items() if v is not None} + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + ClimaCellWeatherEntity(config_entry, coordinator, forecast_type) + for forecast_type in [DAILY, HOURLY, NOWCAST] + ] + async_add_entities(entities) + + +class ClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): + """Entity that talks to ClimaCell API to retrieve weather data.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: ClimaCellDataUpdateCoordinator, + forecast_type: str, + ) -> None: + """Initialize ClimaCell weather entity.""" + super().__init__(config_entry, coordinator) + self.forecast_type = forecast_type + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + if self.forecast_type == DEFAULT_FORECAST_TYPE: + return True + + return False + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"{super().name} - {self.forecast_type.title()}" + + @property + def unique_id(self) -> str: + """Return the unique id of the entity.""" + return f"{super().unique_id}_{self.forecast_type}" + + @property + def temperature(self): + """Return the platform temperature.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_ATTR_TEMPERATURE) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def pressure(self): + """Return the pressure.""" + pressure = self._get_cc_value(self.coordinator.data[CURRENT], CC_ATTR_PRESSURE) + if self.hass.config.units.is_metric and pressure: + return pressure_convert(pressure, PRESSURE_INHG, PRESSURE_HPA) + return pressure + + @property + def humidity(self): + """Return the humidity.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_ATTR_HUMIDITY) + + @property + def wind_speed(self): + """Return the wind speed.""" + wind_speed = self._get_cc_value( + self.coordinator.data[CURRENT], CC_ATTR_WIND_SPEED + ) + if self.hass.config.units.is_metric and wind_speed: + return distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + return wind_speed + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._get_cc_value( + self.coordinator.data[CURRENT], CC_ATTR_WIND_DIRECTION + ) + + @property + def ozone(self): + """Return the O3 (ozone) level.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_ATTR_OZONE) + + @property + def condition(self): + """Return the condition.""" + return _translate_condition( + self._get_cc_value(self.coordinator.data[CURRENT], CC_ATTR_CONDITION), + is_up(self.hass), + ) + + @property + def visibility(self): + """Return the visibility.""" + visibility = self._get_cc_value( + self.coordinator.data[CURRENT], CC_ATTR_VISIBILITY + ) + if self.hass.config.units.is_metric and visibility: + return distance_convert(visibility, LENGTH_MILES, LENGTH_KILOMETERS) + return visibility + + @property + def forecast(self): + """Return the forecast.""" + # Check if forecasts are available + if not self.coordinator.data[FORECASTS].get(self.forecast_type): + return None + + forecasts = [] + + # Set default values (in cases where keys don't exist), None will be + # returned. Override properties per forecast type as needed + for forecast in self.coordinator.data[FORECASTS][self.forecast_type]: + forecast_dt = dt_util.parse_datetime( + self._get_cc_value(forecast, CC_ATTR_TIMESTAMP) + ) + use_datetime = True + condition = self._get_cc_value(forecast, CC_ATTR_CONDITION) + precipitation = self._get_cc_value(forecast, CC_ATTR_PRECIPITATION) + precipitation_probability = self._get_cc_value( + forecast, CC_ATTR_PRECIPITATION_PROBABILITY + ) + temp = self._get_cc_value(forecast, CC_ATTR_TEMPERATURE) + temp_low = None + wind_direction = self._get_cc_value(forecast, CC_ATTR_WIND_DIRECTION) + wind_speed = self._get_cc_value(forecast, CC_ATTR_WIND_SPEED) + + if self.forecast_type == DAILY: + use_datetime = False + precipitation = self._get_cc_value( + forecast, CC_ATTR_PRECIPITATION_DAILY + ) + temp = next( + ( + self._get_cc_value(item, CC_ATTR_TEMPERATURE_HIGH) + for item in forecast[CC_ATTR_TEMPERATURE] + if "max" in item + ), + temp, + ) + temp_low = next( + ( + self._get_cc_value(item, CC_ATTR_TEMPERATURE_LOW) + for item in forecast[CC_ATTR_TEMPERATURE] + if "min" in item + ), + temp_low, + ) + elif self.forecast_type == NOWCAST: + # Precipitation is forecasted in CONF_TIMESTEP increments but in a + # per hour rate, so value needs to be converted to an amount. + if precipitation: + precipitation = ( + precipitation / 60 * self._config_entry.options[CONF_TIMESTEP] + ) + + forecasts.append( + _forecast_dict( + self.hass, + forecast_dt, + use_datetime, + condition, + precipitation, + precipitation_probability, + temp, + temp_low, + wind_direction, + wind_speed, + ) + ) + + return forecasts diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 99964081277..ca88896c6c2 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,93 +1,153 @@ # Describes the format for available climate services set_aux_heat: + name: Turn on/off auxiliary heater description: Turn auxiliary heater on/off for climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" aux_heat: - description: New value of axillary heater. + name: Auxiliary heating + description: New value of auxiliary heater. + required: true example: true + selector: + boolean: set_preset_mode: + name: Set preset mode description: Set preset mode for climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" preset_mode: - description: New value of preset mode + name: Preset mode + description: New value of preset mode. + required: true example: "away" + selector: + text: set_temperature: + name: Set temperature description: Set target temperature of climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" temperature: + name: Temperature description: New target temperature for HVAC. example: 25 + selector: + number: + min: 0 + max: 250 + step: 0.1 + mode: box target_temp_high: + name: Target temperature high description: New target high temperature for HVAC. + advanced: true example: 26 + selector: + number: + min: 0 + max: 250 + step: 0.1 + mode: box target_temp_low: + name: Target temperature low description: New target low temperature for HVAC. + advanced: true example: 20 + selector: + number: + min: 0 + max: 250 + step: 0.1 + mode: box hvac_mode: + name: HVAC mode description: HVAC operation mode to set temperature to. example: "heat" + selector: + select: + options: + - "off" + - "auto" + - "cool" + - "dry" + - "fan_only" + - "heat_cool" + - "heat" set_humidity: + name: Set target humidity description: Set target humidity of climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" humidity: + name: Humidity description: New target humidity for climate device. + required: true example: 60 + selector: + number: + min: 30 + max: 99 + step: 1 + unit_of_measurement: "%" + mode: slider set_fan_mode: + name: Set fan mode description: Set fan operation for climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.nest" fan_mode: + name: Fan mode description: New value of fan mode. - example: On Low + required: true + example: "low" + selector: + text: set_hvac_mode: + name: Set HVAC mode description: Set HVAC operation mode for climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.nest" hvac_mode: + name: HVAC mode description: New value of operation mode. - example: heat + example: "heat" + selector: + select: + options: + - "off" + - "auto" + - "cool" + - "dry" + - "fan_only" + - "heat_cool" + - "heat" set_swing_mode: + name: Set swing mode description: Set swing operation for climate device. + target: fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.nest" swing_mode: + name: Swing mode description: New value of swing mode. + required: true + example: "horizontal" + selector: + text: turn_on: + name: Turn on description: Turn climate device on. - fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" + target: turn_off: + name: Turn off description: Turn climate device off. - fields: - entity_id: - description: Name(s) of entities to change. - example: "climate.kitchen" + target: diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 7abbefe85ff..2d4714b4c81 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -62,7 +62,11 @@ class AlexaConfig(alexa_config.AbstractConfig): @property def enabled(self): """Return if Alexa is enabled.""" - return self._prefs.alexa_enabled + return ( + self._cloud.is_logged_in + and not self._cloud.subscription_expired + and self._prefs.alexa_enabled + ) @property def supports_auth(self): diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 2ac0bc40252..dffa1e2f306 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -2,7 +2,7 @@ import asyncio import logging -from hass_nabucasa import cloud_api +from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.google_assistant.helpers import AbstractConfig @@ -28,7 +28,9 @@ _LOGGER = logging.getLogger(__name__) class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" - def __init__(self, hass, config, cloud_user: str, prefs: CloudPreferences, cloud): + def __init__( + self, hass, config, cloud_user: str, prefs: CloudPreferences, cloud: Cloud + ): """Initialize the Google config.""" super().__init__(hass) self._config = config @@ -43,7 +45,11 @@ class CloudGoogleConfig(AbstractConfig): @property def enabled(self): """Return if Google is enabled.""" - return self._cloud.is_logged_in and self._prefs.google_enabled + return ( + self._cloud.is_logged_in + and not self._cloud.subscription_expired + and self._prefs.google_enabled + ) @property def entity_config(self): diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml index 20c25225ce2..a7fb6b2f21b 100644 --- a/homeassistant/components/cloud/services.yaml +++ b/homeassistant/components/cloud/services.yaml @@ -1,7 +1,7 @@ # Describes the format for available cloud services remote_connect: - description: Make instance UI available outside over NabuCasa cloud. + description: Make instance UI available outside over NabuCasa cloud remote_disconnect: - description: Disconnect UI from NabuCasa cloud. + description: Disconnect UI from NabuCasa cloud diff --git a/homeassistant/components/cloud/translations/nl.json b/homeassistant/components/cloud/translations/nl.json new file mode 100644 index 00000000000..d9aa78afecb --- /dev/null +++ b/homeassistant/components/cloud/translations/nl.json @@ -0,0 +1,15 @@ +{ + "system_health": { + "info": { + "alexa_enabled": "Alexa ingeschakeld", + "can_reach_cloud": "Bereik Home Assistant Cloud", + "can_reach_cloud_auth": "Bereik authenticatieserver", + "google_enabled": "Google ingeschakeld", + "logged_in": "Ingelogd", + "relayer_connected": "Relayer verbonden", + "remote_connected": "Op afstand verbonden", + "remote_enabled": "Op afstand ingeschakeld", + "subscription_expiration": "Afloop abonnement" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index c96e6455ce9..066fff9f704 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -97,7 +97,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - assert self.hass persistent_notification.async_dismiss(self.hass, "cloudflare_setup") errors = {} diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml index 23ffdd14d5f..80165700dbb 100644 --- a/homeassistant/components/cloudflare/services.yaml +++ b/homeassistant/components/cloudflare/services.yaml @@ -1,2 +1,2 @@ update_records: - description: Manually trigger update to Cloudflare records. + description: Manually trigger update to Cloudflare records diff --git a/homeassistant/components/cloudflare/translations/de.json b/homeassistant/components/cloudflare/translations/de.json index d9858b36f55..21118e106bf 100644 --- a/homeassistant/components/cloudflare/translations/de.json +++ b/homeassistant/components/cloudflare/translations/de.json @@ -14,18 +14,21 @@ "records": { "data": { "records": "Datens\u00e4tze" - } + }, + "title": "W\u00e4hle die Records, die aktualisiert werden sollen" }, "user": { "data": { "api_token": "API Token" }, + "description": "F\u00fcr diese Integration ist ein API-Token erforderlich, der mit Zone: Zone: Lesen und Zone: DNS: Bearbeiten f\u00fcr alle Zonen in deinem Konto erstellt wurde.", "title": "Mit Cloudflare verbinden" }, "zone": { "data": { "zone": "Zone" - } + }, + "title": "W\u00e4hle die Zone, die aktualisiert werden soll" } } } diff --git a/homeassistant/components/cloudflare/translations/ko.json b/homeassistant/components/cloudflare/translations/ko.json new file mode 100644 index 00000000000..d4f4eee49a8 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_token": "API \ud1a0\ud070" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/nl.json b/homeassistant/components/cloudflare/translations/nl.json index 37162761d86..35c765d5da7 100644 --- a/homeassistant/components/cloudflare/translations/nl.json +++ b/homeassistant/components/cloudflare/translations/nl.json @@ -1,7 +1,33 @@ { "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", + "unknown": "Onverwachte fout" + }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "invalid_zone": "Ongeldige zone" + }, + "flow_title": "Cloudflare: {name}", + "step": { + "records": { + "data": { + "records": "Records" + }, + "title": "Kies de records die u wilt bijwerken" + }, + "user": { + "data": { + "api_token": "API-token" + }, + "title": "Verbinden met Cloudflare" + }, + "zone": { + "data": { + "zone": "Zone" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/ru.json b/homeassistant/components/cloudflare/translations/ru.json index fa4819d8c83..7c397faa37e 100644 --- a/homeassistant/components/cloudflare/translations/ru.json +++ b/homeassistant/components/cloudflare/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_zone": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0437\u043e\u043d\u0430" }, "flow_title": "Cloudflare: {name}", diff --git a/homeassistant/components/cloudflare/translations/zh-Hant.json b/homeassistant/components/cloudflare/translations/zh-Hant.json index 1be70def034..d9a05269748 100644 --- a/homeassistant/components/cloudflare/translations/zh-Hant.json +++ b/homeassistant/components/cloudflare/translations/zh-Hant.json @@ -19,9 +19,9 @@ }, "user": { "data": { - "api_token": "API \u5bc6\u9470" + "api_token": "API \u6b0a\u6756" }, - "description": "\u6b64\u6574\u5408\u9700\u8981\u5e33\u865f\u4e2d\u6240\u6709\u5340\u57df Zone:Zone:Read \u8207 Zone:DNS:Edit \u6b0a\u9650 API \u5bc6\u9470\u3002", + "description": "\u6b64\u6574\u5408\u9700\u8981\u5e33\u865f\u4e2d\u6240\u6709\u5340\u57df Zone:Zone:Read \u8207 Zone:DNS:Edit \u6b0a\u9650 API \u6b0a\u6756\u3002", "title": "\u9023\u7dda\u81f3 Cloudflare" }, "zone": { diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index 73a55fda8e3..49c10ab92a5 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -64,37 +64,66 @@ def setup_platform(hass, config, add_entities, discover_info=None): port = config[CONF_PORT] name = config[CONF_NAME] - try: - cmus_remote = CmusDevice(host, password, port, name) - except exceptions.InvalidPassword: - _LOGGER.error("The provided password was rejected by cmus") - return False - add_entities([cmus_remote], True) + cmus_remote = CmusRemote(server=host, port=port, password=password) + cmus_remote.connect() + + if cmus_remote.cmus is None: + return + + add_entities([CmusDevice(device=cmus_remote, name=name, server=host)], True) + + +class CmusRemote: + """Representation of a cmus connection.""" + + def __init__(self, server, port, password): + """Initialize the cmus remote.""" + + self._server = server + self._port = port + self._password = password + self.cmus = None + + def connect(self): + """Connect to the cmus server.""" + + try: + self.cmus = remote.PyCmus( + server=self._server, port=self._port, password=self._password + ) + except exceptions.InvalidPassword: + _LOGGER.error("The provided password was rejected by cmus") class CmusDevice(MediaPlayerEntity): """Representation of a running cmus.""" # pylint: disable=no-member - def __init__(self, server, password, port, name): + def __init__(self, device, name, server): """Initialize the CMUS device.""" + self._remote = device if server: - self.cmus = remote.PyCmus(server=server, password=password, port=port) auto_name = f"cmus-{server}" else: - self.cmus = remote.PyCmus() auto_name = "cmus-local" self._name = name or auto_name self.status = {} def update(self): """Get the latest data and update the state.""" - status = self.cmus.get_status_dict() - if not status: - _LOGGER.warning("Received no status from cmus") + try: + status = self._remote.cmus.get_status_dict() + except BrokenPipeError: + self._remote.connect() + except exceptions.ConfigurationError: + _LOGGER.warning("A configuration error occurred") + self._remote.connect() else: self.status = status + return + + _LOGGER.warning("Received no status from cmus") @property def name(self): @@ -168,15 +197,15 @@ class CmusDevice(MediaPlayerEntity): def turn_off(self): """Service to send the CMUS the command to stop playing.""" - self.cmus.player_stop() + self._remote.cmus.player_stop() def turn_on(self): """Service to send the CMUS the command to start playing.""" - self.cmus.player_play() + self._remote.cmus.player_play() def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self.cmus.set_volume(int(volume * 100)) + self._remote.cmus.set_volume(int(volume * 100)) def volume_up(self): """Set the volume up.""" @@ -188,7 +217,7 @@ class CmusDevice(MediaPlayerEntity): current_volume = left if current_volume <= 100: - self.cmus.set_volume(int(current_volume) + 5) + self._remote.cmus.set_volume(int(current_volume) + 5) def volume_down(self): """Set the volume down.""" @@ -200,12 +229,12 @@ class CmusDevice(MediaPlayerEntity): current_volume = left if current_volume <= 100: - self.cmus.set_volume(int(current_volume) - 5) + self._remote.cmus.set_volume(int(current_volume) - 5) def play_media(self, media_type, media_id, **kwargs): """Send the play command.""" if media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST]: - self.cmus.player_play_file(media_id) + self._remote.cmus.player_play_file(media_id) else: _LOGGER.error( "Invalid media type %s. Only %s and %s are supported", @@ -216,24 +245,24 @@ class CmusDevice(MediaPlayerEntity): def media_pause(self): """Send the pause command.""" - self.cmus.player_pause() + self._remote.cmus.player_pause() def media_next_track(self): """Send next track command.""" - self.cmus.player_next() + self._remote.cmus.player_next() def media_previous_track(self): """Send next track command.""" - self.cmus.player_prev() + self._remote.cmus.player_prev() def media_seek(self, position): """Send seek command.""" - self.cmus.seek(position) + self._remote.cmus.seek(position) def media_play(self): """Send the play command.""" - self.cmus.player_play() + self._remote.cmus.player_play() def media_stop(self): """Send the stop command.""" - self.cmus.stop() + self._remote.cmus.stop() diff --git a/homeassistant/components/coinmarketcap/__init__.py b/homeassistant/components/coinmarketcap/__init__.py deleted file mode 100644 index 0cdb5a16a4a..00000000000 --- a/homeassistant/components/coinmarketcap/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The coinmarketcap component.""" diff --git a/homeassistant/components/coinmarketcap/manifest.json b/homeassistant/components/coinmarketcap/manifest.json deleted file mode 100644 index e3f827f2718..00000000000 --- a/homeassistant/components/coinmarketcap/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "coinmarketcap", - "name": "CoinMarketCap", - "documentation": "https://www.home-assistant.io/integrations/coinmarketcap", - "requirements": ["coinmarketcap==5.0.3"], - "codeowners": [] -} diff --git a/homeassistant/components/coinmarketcap/sensor.py b/homeassistant/components/coinmarketcap/sensor.py deleted file mode 100644 index f3fe240c0bc..00000000000 --- a/homeassistant/components/coinmarketcap/sensor.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Details about crypto currencies from CoinMarketCap.""" -from datetime import timedelta -import logging -from urllib.error import HTTPError - -from coinmarketcap import Market -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -ATTR_VOLUME_24H = "volume_24h" -ATTR_AVAILABLE_SUPPLY = "available_supply" -ATTR_CIRCULATING_SUPPLY = "circulating_supply" -ATTR_MARKET_CAP = "market_cap" -ATTR_PERCENT_CHANGE_24H = "percent_change_24h" -ATTR_PERCENT_CHANGE_7D = "percent_change_7d" -ATTR_PERCENT_CHANGE_1H = "percent_change_1h" -ATTR_PRICE = "price" -ATTR_RANK = "rank" -ATTR_SYMBOL = "symbol" -ATTR_TOTAL_SUPPLY = "total_supply" - -ATTRIBUTION = "Data provided by CoinMarketCap" - -CONF_CURRENCY_ID = "currency_id" -CONF_DISPLAY_CURRENCY_DECIMALS = "display_currency_decimals" - -DEFAULT_CURRENCY_ID = 1 -DEFAULT_DISPLAY_CURRENCY = "USD" -DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2 - -ICON = "mdi:currency-usd" - -SCAN_INTERVAL = timedelta(minutes=15) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_CURRENCY_ID, default=DEFAULT_CURRENCY_ID): cv.positive_int, - vol.Optional(CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY): vol.All( - cv.string, vol.Upper - ), - vol.Optional( - CONF_DISPLAY_CURRENCY_DECIMALS, default=DEFAULT_DISPLAY_CURRENCY_DECIMALS - ): vol.All(vol.Coerce(int), vol.Range(min=1)), - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the CoinMarketCap sensor.""" - currency_id = config[CONF_CURRENCY_ID] - display_currency = config[CONF_DISPLAY_CURRENCY] - display_currency_decimals = config[CONF_DISPLAY_CURRENCY_DECIMALS] - - try: - CoinMarketCapData(currency_id, display_currency).update() - except HTTPError: - _LOGGER.warning( - "Currency ID %s or display currency %s " - "is not available. Using 1 (bitcoin) " - "and USD", - currency_id, - display_currency, - ) - currency_id = DEFAULT_CURRENCY_ID - display_currency = DEFAULT_DISPLAY_CURRENCY - - add_entities( - [ - CoinMarketCapSensor( - CoinMarketCapData(currency_id, display_currency), - display_currency_decimals, - ) - ], - True, - ) - - -class CoinMarketCapSensor(Entity): - """Representation of a CoinMarketCap sensor.""" - - def __init__(self, data, display_currency_decimals): - """Initialize the sensor.""" - self.data = data - self.display_currency_decimals = display_currency_decimals - self._ticker = None - self._unit_of_measurement = self.data.display_currency - - @property - def name(self): - """Return the name of the sensor.""" - return self._ticker.get("name") - - @property - def state(self): - """Return the state of the sensor.""" - return round( - float( - self._ticker.get("quotes").get(self.data.display_currency).get("price") - ), - self.display_currency_decimals, - ) - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - return { - ATTR_VOLUME_24H: self._ticker.get("quotes") - .get(self.data.display_currency) - .get("volume_24h"), - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_CIRCULATING_SUPPLY: self._ticker.get("circulating_supply"), - ATTR_MARKET_CAP: self._ticker.get("quotes") - .get(self.data.display_currency) - .get("market_cap"), - ATTR_PERCENT_CHANGE_24H: self._ticker.get("quotes") - .get(self.data.display_currency) - .get("percent_change_24h"), - ATTR_PERCENT_CHANGE_7D: self._ticker.get("quotes") - .get(self.data.display_currency) - .get("percent_change_7d"), - ATTR_PERCENT_CHANGE_1H: self._ticker.get("quotes") - .get(self.data.display_currency) - .get("percent_change_1h"), - ATTR_RANK: self._ticker.get("rank"), - ATTR_SYMBOL: self._ticker.get("symbol"), - ATTR_TOTAL_SUPPLY: self._ticker.get("total_supply"), - } - - def update(self): - """Get the latest data and updates the states.""" - self.data.update() - self._ticker = self.data.ticker.get("data") - - -class CoinMarketCapData: - """Get the latest data and update the states.""" - - def __init__(self, currency_id, display_currency): - """Initialize the data object.""" - self.currency_id = currency_id - self.display_currency = display_currency - self.ticker = None - - def update(self): - """Get the latest data from coinmarketcap.com.""" - - self.ticker = Market().ticker(self.currency_id, convert=self.display_currency) diff --git a/homeassistant/components/color_extractor/services.yaml b/homeassistant/components/color_extractor/services.yaml index fa97dacf3d1..00438dc9aa1 100644 --- a/homeassistant/components/color_extractor/services.yaml +++ b/homeassistant/components/color_extractor/services.yaml @@ -1,12 +1,23 @@ turn_on: - description: Set the light RGB to the predominant color found in the image provided by url or file path. + name: Turn on + description: + Set the light RGB to the predominant color found in the image provided by + URL or file path. + target: fields: color_extract_url: - description: The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls. + name: URL + description: + The URL of the image we want to extract RGB values from. Must be allowed + in allowlist_external_urls. example: https://www.example.com/images/logo.png + selector: + text: color_extract_path: - description: The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs. + name: Path + description: + The full system path to the image we want to extract RGB values from. + Must be allowed in allowlist_external_dirs. example: /opt/images/logo.png - entity_id: - description: The entity we want to set our RGB color on. - example: "light.living_room_shelves" + selector: + text: diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index b5eac4f9afe..26abd85522a 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -1,5 +1,6 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" import logging +import math from pycomfoconnect import ( CMD_FAN_MODE_AWAY, @@ -9,21 +10,26 @@ from pycomfoconnect import ( SENSOR_FAN_SPEED_MODE, ) -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge _LOGGER = logging.getLogger(__name__) -SPEED_MAPPING = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} +CMD_MAPPING = { + 0: CMD_FAN_MODE_AWAY, + 1: CMD_FAN_MODE_LOW, + 2: CMD_FAN_MODE_MEDIUM, + 3: CMD_FAN_MODE_HIGH, +} + +SPEED_RANGE = (1, 3) # away is not included in speeds and instead mapped to off def setup_platform(hass, config, add_entities, discovery_info=None): @@ -89,41 +95,41 @@ class ComfoConnectFan(FanEntity): return SUPPORT_SET_SPEED @property - def speed(self): - """Return the current fan mode.""" - try: - speed = self._ccb.data[SENSOR_FAN_SPEED_MODE] - return SPEED_MAPPING[speed] - except KeyError: + def percentage(self) -> str: + """Return the current speed percentage.""" + speed = self._ccb.data.get(SENSOR_FAN_SPEED_MODE) + if speed is None: return None + return ranged_value_to_percentage(SPEED_RANGE, speed) @property - def speed_list(self): - """List of available fan modes.""" - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on( + self, speed: str = None, percentage=None, preset_mode=None, **kwargs + ) -> None: """Turn on the fan.""" - if speed is None: - speed = SPEED_LOW - self.set_speed(speed) + self.set_percentage(percentage) def turn_off(self, **kwargs) -> None: """Turn off the fan (to away).""" - self.set_speed(SPEED_OFF) + self.set_percentage(0) - def set_speed(self, speed: str): - """Set fan speed.""" - _LOGGER.debug("Changing fan speed to %s", speed) + def set_percentage(self, percentage: int): + """Set fan speed percentage.""" + _LOGGER.debug("Changing fan speed percentage to %s", percentage) - if speed == SPEED_OFF: - self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) - elif speed == SPEED_LOW: - self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) - elif speed == SPEED_MEDIUM: - self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) - elif speed == SPEED_HIGH: - self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) + if percentage is None: + cmd = CMD_FAN_MODE_LOW + elif percentage == 0: + cmd = CMD_FAN_MODE_AWAY + else: + speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + cmd = CMD_MAPPING[speed] + + self._ccb.comfoconnect.cmd_rmi_request(cmd) # Update current mode self.schedule_update_ha_state() diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 53075beecaf..87fa8f4a1a6 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -29,6 +29,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_ID, CONF_RESOURCES, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -71,8 +73,6 @@ ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -ATTR_ICON = "icon" -ATTR_ID = "id" ATTR_LABEL = "label" ATTR_MULTIPLIER = "multiplier" ATTR_UNIT = "unit" diff --git a/homeassistant/components/command_line/services.yaml b/homeassistant/components/command_line/services.yaml index 8876e8dc925..de010ba8b85 100644 --- a/homeassistant/components/command_line/services.yaml +++ b/homeassistant/components/command_line/services.yaml @@ -1,2 +1,2 @@ reload: - description: Reload all command_line entities. + description: Reload all command_line entities diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 1098594a04c..7d07710a4d0 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -65,11 +65,11 @@ async def async_setup(hass, config): hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) - tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + tasks = [asyncio.create_task(setup_panel(panel_name)) for panel_name in SECTIONS] for panel_name in ON_DEMAND: if panel_name in hass.config.components: - tasks.append(setup_panel(panel_name)) + tasks.append(asyncio.create_task(setup_panel(panel_name))) if tasks: await asyncio.wait(tasks) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 81daf35339e..f40ed7834e3 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -90,7 +90,7 @@ async def websocket_delete_area(hass, connection, msg): registry = await async_get_registry(hass) try: - await registry.async_delete(msg["area_id"]) + registry.async_delete(msg["area_id"]) except KeyError: connection.send_message( websocket_api.error_message( diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index b8d9944d7af..b45b6abe468 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,7 +1,6 @@ """Http views to control the config manager.""" import aiohttp.web_exceptions import voluptuous as vol -import voluptuous_serialize from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT @@ -10,7 +9,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND from homeassistant.core import callback from homeassistant.exceptions import Unauthorized -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, @@ -30,6 +28,7 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + hass.components.websocket_api.async_register_command(config_entry_disable) hass.components.websocket_api.async_register_command(config_entry_update) hass.components.websocket_api.async_register_command(config_entries_progress) hass.components.websocket_api.async_register_command(system_options_list) @@ -39,24 +38,6 @@ async def async_setup(hass): return True -def _prepare_json(result): - """Convert result for JSON.""" - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: - return result - - data = result.copy() - - schema = data["data_schema"] - if schema is None: - data["data_schema"] = [] - else: - data["data_schema"] = voluptuous_serialize.convert( - schema, custom_serializer=cv.custom_serializer - ) - - return data - - class ConfigManagerEntryIndexView(HomeAssistantView): """View to get available config entries.""" @@ -265,6 +246,21 @@ async def system_options_list(hass, connection, msg): connection.send_result(msg["id"], entry.system_options.as_dict()) +def send_entry_not_found(connection, msg_id): + """Send Config entry not found error.""" + connection.send_error( + msg_id, websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + + +def get_entry(hass, connection, entry_id, msg_id): + """Get entry, send error message if it doesn't exist.""" + entry = hass.config_entries.async_get_entry(entry_id) + if entry is None: + send_entry_not_found(connection, msg_id) + return entry + + @websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( @@ -279,13 +275,10 @@ async def system_options_update(hass, connection, msg): changes = dict(msg) changes.pop("id") changes.pop("type") - entry_id = changes.pop("entry_id") - entry = hass.config_entries.async_get_entry(entry_id) + changes.pop("entry_id") + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) if entry is None: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" - ) return hass.config_entries.async_update_entry(entry, system_options=changes) @@ -302,20 +295,47 @@ async def config_entry_update(hass, connection, msg): changes = dict(msg) changes.pop("id") changes.pop("type") - entry_id = changes.pop("entry_id") - - entry = hass.config_entries.async_get_entry(entry_id) + changes.pop("entry_id") + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) if entry is None: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" - ) return hass.config_entries.async_update_entry(entry, **changes) connection.send_result(msg["id"], entry_json(entry)) +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "config_entries/disable", + "entry_id": str, + # We only allow setting disabled_by user via API. + "disabled_by": vol.Any("user", None), + } +) +async def config_entry_disable(hass, connection, msg): + """Disable config entry.""" + disabled_by = msg["disabled_by"] + + result = False + try: + result = await hass.config_entries.async_set_disabled_by( + msg["entry_id"], disabled_by + ) + except config_entries.OperationNotAllowed: + # Failed to unload the config entry + pass + except config_entries.UnknownEntry: + send_entry_not_found(connection, msg["id"]) + return + + result = {"require_restart": not result} + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( @@ -333,9 +353,7 @@ async def ignore_config_flow(hass, connection, msg): ) if flow is None: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" - ) + send_entry_not_found(connection, msg["id"]) return if "unique_id" not in flow["context"]: @@ -357,7 +375,7 @@ 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 compnoent etc) + # 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 @@ -372,4 +390,5 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict: "connection_class": entry.connection_class, "supports_options": supports_options, "supports_unload": entry.supports_unload, + "disabled_by": entry.disabled_by, } diff --git a/homeassistant/components/control4/translations/ru.json b/homeassistant/components/control4/translations/ru.json index 4f51641992b..3882f03cb32 100644 --- a/homeassistant/components/control4/translations/ru.json +++ b/homeassistant/components/control4/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 032edba8db1..edba9ffb0b9 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -1,7 +1,11 @@ # Describes the format for available component services process: + name: Process description: Launch a conversation from a transcribed text. fields: text: + name: Text description: Transcribed text example: Turn all lights on + selector: + text: diff --git a/homeassistant/components/coolmaster/translations/ko.json b/homeassistant/components/coolmaster/translations/ko.json index 5d0636bddcd..82b88394431 100644 --- a/homeassistant/components/coolmaster/translations/ko.json +++ b/homeassistant/components/coolmaster/translations/ko.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "no_units": "CoolMasterNet \ud638\uc2a4\ud2b8\uc5d0\uc11c HVAC \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { diff --git a/homeassistant/components/coronavirus/translations/ko.json b/homeassistant/components/coronavirus/translations/ko.json index 65eec9e8bb7..873aca88e30 100644 --- a/homeassistant/components/coronavirus/translations/ko.json +++ b/homeassistant/components/coronavirus/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc774 \uad6d\uac00\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index ad5e4000116..868a74cc7b7 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -1,4 +1,6 @@ """Component to count within automations.""" +from __future__ import annotations + import logging from typing import Dict, Optional @@ -108,8 +110,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, Counter.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, Counter.from_yaml ) storage_collection = CounterStorageCollection( @@ -117,8 +119,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, Counter + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, Counter ) await yaml_collection.async_load( @@ -130,9 +132,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") @@ -182,7 +181,7 @@ class Counter(RestoreEntity): self.editable: bool = True @classmethod - def from_yaml(cls, config: Dict) -> "Counter": + def from_yaml(cls, config: Dict) -> Counter: """Create counter instance from yaml config.""" counter = cls(config) counter.editable = False diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 960424df0ca..4dd427c1fa1 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -1,41 +1,67 @@ # Describes the format for available counter services decrement: + name: Decrement description: Decrement a counter. - fields: - entity_id: - description: Entity id of the counter to decrement. - example: "counter.count0" + target: + increment: + name: Increment description: Increment a counter. - fields: - entity_id: - description: Entity id of the counter to increment. - example: "counter.count0" + target: + reset: + name: Reset description: Reset a counter. - fields: - entity_id: - description: Entity id of the counter to reset. - example: "counter.count0" + target: + configure: - description: Change counter parameters + name: Configure + description: Change counter parameters. + target: fields: - entity_id: - description: Entity id of the counter to change. - example: "counter.count0" minimum: - description: New minimum value for the counter or None to remove minimum + name: Minimum + description: New minimum value for the counter or None to remove minimum. example: 0 + selector: + number: + min: -9223372036854775807 + max: 9223372036854775807 + mode: box maximum: - description: New maximum value for the counter or None to remove maximum + name: Maximum + description: New maximum value for the counter or None to remove maximum. example: 100 + selector: + number: + min: -9223372036854775807 + max: 9223372036854775807 + mode: box step: - description: New value for step + name: Step + description: New value for step. example: 2 + selector: + number: + min: 1 + max: 9223372036854775807 + mode: box initial: - description: New value for initial + name: Initial + description: New value for initial. example: 6 + selector: + number: + min: 0 + max: 9223372036854775807 + mode: box value: - description: New state value + name: Value + description: New state value. example: 3 + selector: + number: + min: 0 + max: 9223372036854775807 + mode: box diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 29dd97909e3..490ce162d9a 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -49,7 +49,9 @@ POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), - vol.Required("position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + vol.Optional("position", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), } ) diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 604955aa199..1419a5f48ed 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,77 +1,77 @@ # Describes the format for available cover services open_cover: + name: Open description: Open all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to open. - example: "cover.living_room" + target: close_cover: + name: Close description: Close all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to close. - example: "cover.living_room" + target: toggle: - description: Toggles a cover open/closed. - fields: - entity_id: - description: Name(s) of cover(s) to toggle. - example: "cover.garage_door" + name: Toggle + description: Toggle a cover open/closed. + target: set_cover_position: + name: Set position description: Move to specific position all or specified cover. + target: fields: - entity_id: - description: Name(s) of cover(s) to set cover position. - example: "cover.living_room" position: - description: Position of the cover (0 to 100). + name: Position + description: Position of the cover + required: true example: 30 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider stop_cover: + name: Stop description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: "cover.living_room" + target: open_cover_tilt: + name: Open tilt description: Open all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) tilt to open. - example: "cover.living_room_blinds" + target: close_cover_tilt: + name: Close tilt description: Close all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) to close tilt. - example: "cover.living_room_blinds" + target: toggle_cover_tilt: - description: Toggles a cover tilt open/closed. - fields: - entity_id: - description: Name(s) of cover(s) to toggle tilt. - example: "cover.living_room_blinds" + name: Toggle tilt + description: Toggle a cover tilt open/closed. + target: set_cover_tilt_position: + name: Set tilt position description: Move to specific position all or specified cover tilt. + target: fields: - entity_id: - description: Name(s) of cover(s) to set cover tilt position. - example: "cover.living_room_blinds" tilt_position: - description: Tilt position of the cover (0 to 100). + name: Tilt position + description: Tilt position of the cover. + required: true example: 30 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider stop_cover_tilt: + name: Stop tilt description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: "cover.living_room_blinds" + target: diff --git a/homeassistant/components/cover/translations/nl.json b/homeassistant/components/cover/translations/nl.json index 679d9360a82..8b1ca3c3500 100644 --- a/homeassistant/components/cover/translations/nl.json +++ b/homeassistant/components/cover/translations/nl.json @@ -6,7 +6,8 @@ "open": "Open {entity_name}", "open_tilt": "Open de kanteling {entity_name}", "set_position": "Stel de positie van {entity_name} in", - "set_tilt_position": "Stel de {entity_name} kantelpositie in" + "set_tilt_position": "Stel de {entity_name} kantelpositie in", + "stop": "Stop {entity_name}" }, "condition_type": { "is_closed": "{entity_name} is gesloten", diff --git a/homeassistant/components/cover/translations/sv.json b/homeassistant/components/cover/translations/sv.json index 0a8dbecf124..a9509740330 100644 --- a/homeassistant/components/cover/translations/sv.json +++ b/homeassistant/components/cover/translations/sv.json @@ -1,5 +1,9 @@ { "device_automation": { + "action_type": { + "close": "St\u00e4ng {entity_name}", + "open": "\u00d6ppna {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} \u00e4r st\u00e4ngd", "is_closing": "{entity_name} st\u00e4ngs", diff --git a/homeassistant/components/crimereports/__init__.py b/homeassistant/components/crimereports/__init__.py deleted file mode 100644 index 57af9df4dbf..00000000000 --- a/homeassistant/components/crimereports/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The crimereports component.""" diff --git a/homeassistant/components/crimereports/manifest.json b/homeassistant/components/crimereports/manifest.json deleted file mode 100644 index 624d812f5f3..00000000000 --- a/homeassistant/components/crimereports/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "crimereports", - "name": "Crime Reports", - "documentation": "https://www.home-assistant.io/integrations/crimereports", - "requirements": ["crimereports==1.0.1"], - "codeowners": [] -} diff --git a/homeassistant/components/crimereports/sensor.py b/homeassistant/components/crimereports/sensor.py deleted file mode 100644 index 8919b2d09b1..00000000000 --- a/homeassistant/components/crimereports/sensor.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Sensor for Crime Reports.""" -from collections import defaultdict -from datetime import timedelta - -import crimereports -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_EXCLUDE, - CONF_INCLUDE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - CONF_RADIUS, - LENGTH_KILOMETERS, - LENGTH_METERS, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify -from homeassistant.util.distance import convert -from homeassistant.util.dt import now - -DOMAIN = "crimereports" - -EVENT_INCIDENT = f"{DOMAIN}_incident" - -SCAN_INTERVAL = timedelta(minutes=30) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_RADIUS): vol.Coerce(float), - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Crime Reports platform.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config[CONF_NAME] - radius = config[CONF_RADIUS] - include = config.get(CONF_INCLUDE) - exclude = config.get(CONF_EXCLUDE) - - add_entities( - [CrimeReportsSensor(hass, name, latitude, longitude, radius, include, exclude)], - True, - ) - - -class CrimeReportsSensor(Entity): - """Representation of a Crime Reports Sensor.""" - - def __init__(self, hass, name, latitude, longitude, radius, include, exclude): - """Initialize the Crime Reports sensor.""" - self._hass = hass - self._name = name - self._include = include - self._exclude = exclude - radius_kilometers = convert(radius, LENGTH_METERS, LENGTH_KILOMETERS) - self._crimereports = crimereports.CrimeReports( - (latitude, longitude), radius_kilometers - ) - self._attributes = None - self._state = None - self._previous_incidents = set() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - def _incident_event(self, incident): - """Fire if an event occurs.""" - data = { - "type": incident.get("type"), - "description": incident.get("friendly_description"), - "timestamp": incident.get("timestamp"), - "location": incident.get("location"), - } - if incident.get("coordinates"): - data.update( - { - ATTR_LATITUDE: incident.get("coordinates")[0], - ATTR_LONGITUDE: incident.get("coordinates")[1], - } - ) - self._hass.bus.fire(EVENT_INCIDENT, data) - - def update(self): - """Update device state.""" - incident_counts = defaultdict(int) - incidents = self._crimereports.get_incidents( - now().date(), include=self._include, exclude=self._exclude - ) - fire_events = len(self._previous_incidents) > 0 - if len(incidents) < len(self._previous_incidents): - self._previous_incidents = set() - for incident in incidents: - incident_type = slugify(incident.get("type")) - incident_counts[incident_type] += 1 - if fire_events and incident.get("id") not in self._previous_incidents: - self._incident_event(incident) - self._previous_incidents.add(incident.get("id")) - self._attributes = {ATTR_ATTRIBUTION: crimereports.ATTRIBUTION} - self._attributes.update(incident_counts) - self._state = len(incidents) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index b4950b8b05b..16fd2b2ff56 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -16,7 +16,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle -from . import config_flow # noqa: F401 from .const import CONF_UUID, KEY_MAC, TIMEOUT _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/daikin/translations/ko.json b/homeassistant/components/daikin/translations/ko.json index 9c4a6c8d50c..e87db9f29d3 100644 --- a/homeassistant/components/daikin/translations/ko.json +++ b/homeassistant/components/daikin/translations/ko.json @@ -4,13 +4,19 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { + "api_key": "API \ud0a4", "host": "\ud638\uc2a4\ud2b8", "password": "\ube44\ubc00\ubc88\ud638" }, - "description": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\nAPI \ud0a4 \ubc0f \ube44\ubc00\ubc88\ud638\ub294 BRP072Cxx \uc640 SKYFi \uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ub41c\ub2e4\ub294 \uc810\uc5d0 \uc720\uc758\ud558\uc138\uc694.", + "description": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\nAPI \ud0a4 \ubc0f \ube44\ubc00\ubc88\ud638\ub294 BRP072Cxx \uc640 SKYFi \uae30\uae30\uc5d0\uc11c \uc0ac\uc6a9\ub41c\ub2e4\ub294 \uc810\uc5d0 \uc720\uc758\ud574\uc8fc\uc138\uc694.", "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8 \uad6c\uc131\ud558\uae30" } } diff --git a/homeassistant/components/daikin/translations/nl.json b/homeassistant/components/daikin/translations/nl.json index 2d1e1edbdbb..e4cf54eb365 100644 --- a/homeassistant/components/daikin/translations/nl.json +++ b/homeassistant/components/daikin/translations/nl.json @@ -4,6 +4,11 @@ "already_configured": "Apparaat is al geconfigureerd", "cannot_connect": "Kon niet verbinden" }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/daikin/translations/ru.json b/homeassistant/components/daikin/translations/ru.json index df7d9fb07dc..7365bb0e7bb 100644 --- a/homeassistant/components/daikin/translations/ru.json +++ b/homeassistant/components/daikin/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/debugpy/services.yaml b/homeassistant/components/debugpy/services.yaml index 4e3c19dd0d7..6bf9ad67288 100644 --- a/homeassistant/components/debugpy/services.yaml +++ b/homeassistant/components/debugpy/services.yaml @@ -1,3 +1,3 @@ # Describes the format for available Remote Python Debugger services start: - description: Start the Remote Python Debugger. + description: Start the Remote Python Debugger diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index fec7b82e365..1b8c47d36a2 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,11 +1,17 @@ """Support for deCONZ devices.""" import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.typing import UNDEFINED +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 .config_flow import get_master_gateway -from .const import CONF_BRIDGE_ID, CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN +from .const import CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN from .gateway import DeconzGateway from .services import async_setup_services, async_unload_services @@ -28,6 +34,8 @@ async def async_setup_entry(hass, config_entry): if DOMAIN not in hass.data: hass.data[DOMAIN] = {} + await async_update_group_unique_id(hass, config_entry) + if not config_entry.options: await async_update_master_gateway(hass, config_entry) @@ -36,18 +44,6 @@ async def async_setup_entry(hass, config_entry): if not await gateway.async_setup(): return False - # 0.104 introduced config entry unique id, this makes upgrading possible - if config_entry.unique_id is None: - - new_data = UNDEFINED - if CONF_BRIDGE_ID in config_entry.data: - new_data = dict(config_entry.data) - new_data[CONF_GROUP_ID_BASE] = config_entry.data[CONF_BRIDGE_ID] - - hass.config_entries.async_update_entry( - config_entry, unique_id=gateway.api.config.bridgeid, data=new_data - ) - hass.data[DOMAIN][config_entry.unique_id] = gateway await gateway.async_update_device_registry() @@ -84,3 +80,30 @@ async def async_update_master_gateway(hass, config_entry): 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: + """Update unique ID entities based on deCONZ groups.""" + if not (old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE)): + return + + new_unique_id: str = config_entry.unique_id + + @callback + def update_unique_id(entity_entry): + """Update unique ID of entity entry.""" + if f"{old_unique_id}-" not in entity_entry.unique_id: + return None + return { + "new_unique_id": entity_entry.unique_id.replace( + old_unique_id, new_unique_id + ) + } + + await 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], + CONF_PORT: config_entry.data[CONF_PORT], + } + hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 98e3864e191..44111fbbb1e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -26,7 +26,7 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ATTR_OFFSET, ATTR_VALVE, NEW_SENSOR +from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -254,4 +254,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if self._device.valve is not None: attr[ATTR_VALVE] = self._device.valve + if self._device.locked is not None: + attr[ATTR_LOCKED] = self._device.locked + return attr diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index bc14af9ff11..d1ea3826e2f 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -176,7 +176,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, config: dict): """Trigger a reauthentication flow.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} self.deconz_config = { @@ -207,7 +206,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): updates={CONF_HOST: parsed_url.hostname, CONF_PORT: parsed_url.port} ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"host": parsed_url.hostname} self.deconz_config = { diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index cbad37b1b87..67effa4e81b 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -46,6 +46,7 @@ NEW_SCENE = "scenes" NEW_SENSOR = "sensors" ATTR_DARK = "dark" +ATTR_LOCKED = "locked" ATTR_OFFSET = "offset" ATTR_ON = "on" ATTR_VALVE = "valve" diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index d92addff5bd..1ca4c8ff9c2 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -107,7 +107,20 @@ class DeconzFan(DeconzDevice, FanEntity): await self._device.set_speed(SPEEDS[speed]) - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on fan.""" if not speed: speed = convert_speed(self._default_on_speed) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 9080160c76f..2da435c5530 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -26,7 +26,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import ( - CONF_GROUP_ID_BASE, COVER_TYPES, DOMAIN as DECONZ_DOMAIN, LOCK_TYPES, @@ -248,10 +247,7 @@ class DeconzGroup(DeconzBaseLight): def __init__(self, device, gateway): """Set up group and create an unique id.""" - group_id_base = gateway.config_entry.unique_id - if CONF_GROUP_ID_BASE in gateway.config_entry.data: - group_id_base = gateway.config_entry.data[CONF_GROUP_ID_BASE] - self._unique_id = f"{group_id_base}-{device.deconz_id}" + self._unique_id = f"{gateway.bridgeid}-{device.deconz_id}" super().__init__(device, gateway) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index 9d85e76d8d3..1703037fc64 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -11,7 +11,7 @@ configure: entity (when entity is specified). example: '"/lights/1/state" or "/state"' data: - description: Data is a json object with what data you want to alter. + description: Data is a JSON object with what data you want to alter. example: '{"on": true}' bridgeid: description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. diff --git a/homeassistant/components/deconz/translations/de.json b/homeassistant/components/deconz/translations/de.json index d7553652412..75b807b8848 100644 --- a/homeassistant/components/deconz/translations/de.json +++ b/homeassistant/components/deconz/translations/de.json @@ -66,6 +66,7 @@ "remote_button_quadruple_press": "\"{subtype}\" Taste vierfach geklickt", "remote_button_quintuple_press": "\"{subtype}\" Taste f\u00fcnffach geklickt", "remote_button_rotated": "Button gedreht \"{subtype}\".", + "remote_button_rotated_fast": "Button schnell gedreht \"{subtype}\"", "remote_button_rotation_stopped": "Die Tastendrehung \"{subtype}\" wurde gestoppt", "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt", "remote_button_short_release": "\"{subtype}\" Taste losgelassen", diff --git a/homeassistant/components/deconz/translations/et.json b/homeassistant/components/deconz/translations/et.json index ad5c07b6607..9f6644ea186 100644 --- a/homeassistant/components/deconz/translations/et.json +++ b/homeassistant/components/deconz/translations/et.json @@ -18,7 +18,7 @@ "title": "deCONZ Zigbee v\u00e4rav Hass.io pistikprogrammi kaudu" }, "link": { - "description": "Home Assistanti registreerumiseks ava deCONZ-i l\u00fc\u00fcs.\n\n 1. Minge deCONZ Settings - > Gateway - > Advanced\n 2. Vajutage nuppu \"Authenticate app\"", + "description": "Home Assistanti registreerumiseks ava deCONZ-i l\u00fc\u00fcs.\n\n 1. Mine deCONZ Settings - > Gateway - > Advanced\n 2. Vajuta nuppu \"Authenticate app\"", "title": "\u00dchenda deCONZ-iga" }, "manual_input": { diff --git a/homeassistant/components/deconz/translations/ko.json b/homeassistant/components/deconz/translations/ko.json index 6c7dde04e31..bd8aef75dd6 100644 --- a/homeassistant/components/deconz/translations/ko.json +++ b/homeassistant/components/deconz/translations/ko.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\ube0c\ub9ac\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9ac\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", "not_deconz_bridge": "deCONZ \ube0c\ub9ac\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", "updated_instance": "deCONZ \uc778\uc2a4\ud134\uc2a4\ub97c \uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/deconz/translations/sv.json b/homeassistant/components/deconz/translations/sv.json index 225b9f0b4e3..4d709a43af1 100644 --- a/homeassistant/components/deconz/translations/sv.json +++ b/homeassistant/components/deconz/translations/sv.json @@ -87,7 +87,8 @@ "allow_clip_sensor": "Till\u00e5t deCONZ CLIP-sensorer", "allow_deconz_groups": "Till\u00e5t deCONZ ljusgrupper" }, - "description": "Konfigurera synlighet f\u00f6r deCONZ-enhetstyper" + "description": "Konfigurera synlighet f\u00f6r deCONZ-enhetstyper", + "title": "deCONZ-inst\u00e4llningar" } } } diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index f8be3c9fe2a..0f4b940cc36 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -18,6 +18,7 @@ "map", "media_source", "mobile_app", + "my", "person", "scene", "script", diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 8c73fecf26e..0058816d318 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -6,7 +6,7 @@ from pydelijn.common import HttpException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, DEVICE_CLASS_TIMESTAMP +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -17,7 +17,6 @@ ATTRIBUTION = "Data provided by data.delijn.be" CONF_NEXT_DEPARTURE = "next_departure" CONF_STOP_ID = "stop_id" -CONF_API_KEY = "api_key" CONF_NUMBER_OF_DEPARTURES = "number_of_departures" DEFAULT_NAME = "De Lijn" diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index ee49f0a2e99..c79b53c0918 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,14 +1,22 @@ """Demo fan platform that has a fake fan.""" +from typing import List, Optional + from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import STATE_OFF + +PRESET_MODE_AUTO = "auto" +PRESET_MODE_SMART = "smart" +PRESET_MODE_SLEEP = "sleep" +PRESET_MODE_ON = "on" FULL_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION LIMITED_SUPPORT = SUPPORT_SET_SPEED @@ -18,8 +26,72 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the demo fan platform.""" async_add_entities( [ - DemoFan(hass, "fan1", "Living Room Fan", FULL_SUPPORT), - DemoFan(hass, "fan2", "Ceiling Fan", LIMITED_SUPPORT), + # These fans implement the old model + DemoFan( + hass, + "fan1", + "Living Room Fan", + FULL_SUPPORT, + None, + [ + SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ], + ), + DemoFan( + hass, + "fan2", + "Ceiling Fan", + LIMITED_SUPPORT, + None, + [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], + ), + # These fans implement the newer model + AsyncDemoPercentageFan( + hass, + "fan3", + "Percentage Full Fan", + FULL_SUPPORT, + [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ], + None, + ), + DemoPercentageFan( + hass, + "fan4", + "Percentage Limited Fan", + LIMITED_SUPPORT, + [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ], + None, + ), + AsyncDemoPercentageFan( + hass, + "fan5", + "Preset Only Limited Fan", + SUPPORT_PRESET_MODE, + [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ], + [], + ), ] ) @@ -29,21 +101,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): await async_setup_platform(hass, {}, async_add_entities) -class DemoFan(FanEntity): - """A demonstration fan component.""" +class BaseDemoFan(FanEntity): + """A demonstration fan component that uses legacy fan speeds.""" def __init__( - self, hass, unique_id: str, name: str, supported_features: int + self, + hass, + unique_id: str, + name: str, + supported_features: int, + preset_modes: Optional[List[str]], + speed_list: Optional[List[str]], ) -> None: """Initialize the entity.""" self.hass = hass self._unique_id = unique_id self._supported_features = supported_features - self._speed = STATE_OFF + self._speed = SPEED_OFF + self._percentage = None + self._speed_list = speed_list + self._preset_modes = preset_modes + self._preset_mode = None self._oscillating = None self._direction = None self._name = name - if supported_features & SUPPORT_OSCILLATE: self._oscillating = False if supported_features & SUPPORT_DIRECTION: @@ -64,17 +145,42 @@ class DemoFan(FanEntity): """No polling needed for a demo fan.""" return False + @property + def current_direction(self) -> str: + """Fan direction.""" + return self._direction + + @property + def oscillating(self) -> bool: + """Oscillating.""" + return self._oscillating + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + +class DemoFan(BaseDemoFan, FanEntity): + """A demonstration fan component that uses legacy fan speeds.""" + @property def speed(self) -> str: """Return the current speed.""" return self._speed @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + def speed_list(self): + """Return the speed list.""" + return self._speed_list - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM @@ -83,7 +189,7 @@ class DemoFan(FanEntity): def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.oscillate(False) - self.set_speed(STATE_OFF) + self.set_speed(SPEED_OFF) def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" @@ -100,17 +206,134 @@ class DemoFan(FanEntity): self._oscillating = oscillating self.schedule_update_ha_state() - @property - def current_direction(self) -> str: - """Fan direction.""" - return self._direction + +class DemoPercentageFan(BaseDemoFan, FanEntity): + """A demonstration fan component that uses percentages.""" @property - def oscillating(self) -> bool: - """Oscillating.""" - return self._oscillating + def percentage(self) -> str: + """Return the current speed.""" + return self._percentage @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._supported_features + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return 3 + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + self._percentage = percentage + self._preset_mode = None + self.schedule_update_ha_state() + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + return self._preset_mode + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode in self.preset_modes: + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() + else: + raise ValueError(f"Invalid preset mode: {preset_mode}") + + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + if preset_mode: + self.set_preset_mode(preset_mode) + return + + if percentage is None: + percentage = 67 + + self.set_percentage(percentage) + + def turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + self.set_percentage(0) + + +class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): + """An async demonstration fan component that uses percentages.""" + + @property + def percentage(self) -> str: + """Return the current speed.""" + return self._percentage + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return 3 + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + self._percentage = percentage + self._preset_mode = None + self.async_write_ha_state() + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + return self._preset_mode + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode not in self.preset_modes: + raise ValueError( + "{preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + self._preset_mode = preset_mode + self._percentage = None + self.async_write_ha_state() + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + if preset_mode: + await self.async_set_preset_mode(preset_mode) + return + + if percentage is None: + percentage = 67 + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + await self.async_oscillate(False) + await self.async_set_percentage(0) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._direction = direction + self.async_write_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + self._oscillating = oscillating + self.async_write_ha_state() diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 5a6ce5f5c64..f3fd815f621 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -21,7 +21,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= DemoNumber( "pwm1", "PWM 1", - 42.0, + 0.42, "mdi:square-wave", False, 0.0, diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 89c6413d146..3946a0d6171 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST +from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import dispatcher_send @@ -24,7 +24,6 @@ from .receiver import ConnectDenonAVR CONF_RECEIVER = "receiver" UNDO_UPDATE_LISTENER = "undo_update_listener" SERVICE_GET_COMMAND = "get_command" -ATTR_COMMAND = "command" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 21c8da01783..0b7c0b71847 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -25,7 +25,6 @@ IGNORED_MODELS = ["HEOS 1", "HEOS 3", "HEOS 5", "HEOS 7"] CONF_SHOW_ALL_SOURCES = "show_all_sources" CONF_ZONE2 = "zone2" CONF_ZONE3 = "zone3" -CONF_TYPE = "type" CONF_MODEL = "model" CONF_MANUFACTURER = "manufacturer" CONF_SERIAL_NUMBER = "serial_number" @@ -225,7 +224,6 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { "title_placeholders": { diff --git a/homeassistant/components/denonavr/services.yaml b/homeassistant/components/denonavr/services.yaml index c9831a68aa5..35dedd8fb7f 100644 --- a/homeassistant/components/denonavr/services.yaml +++ b/homeassistant/components/denonavr/services.yaml @@ -1,7 +1,7 @@ # Describes the format for available webostv services get_command: - description: "Send a generic http get command." + description: "Send a generic HTTP get command." fields: entity_id: description: Name(s) of the denonavr entities where to run the API method. diff --git a/homeassistant/components/denonavr/translations/ko.json b/homeassistant/components/denonavr/translations/ko.json index f995b852a57..71562ac53a4 100644 --- a/homeassistant/components/denonavr/translations/ko.json +++ b/homeassistant/components/denonavr/translations/ko.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "Denon AVR \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "not_denonavr_manufacturer": "Denon AVR \ub124\ud2b8\uc6cc\ud06c \ub9ac\uc2dc\ubc84\uac00 \uc544\ub2d9\ub2c8\ub2e4. \ubc1c\uacac\ub41c \uc81c\uc870\uc0ac\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "not_denonavr_missing": "Denon AVR \ub124\ud2b8\uc6cc\ud06c \ub9ac\uc2dc\ubc84\uac00 \uc544\ub2d9\ub2c8\ub2e4. \uac80\uc0c9 \uc815\ubcf4\uac00 \uc644\uc804\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, @@ -17,7 +17,7 @@ }, "select": { "data": { - "select_host": "\ub9ac\uc2dc\ubc84 IP" + "select_host": "\ub9ac\uc2dc\ubc84 IP \uc8fc\uc18c" }, "description": "\ub9ac\uc2dc\ubc84 \uc5f0\uacb0\uc744 \ucd94\uac00\ud558\ub824\uba74 \uc124\uc815\uc744 \ub2e4\uc2dc \uc2e4\ud589\ud574\uc8fc\uc138\uc694", "title": "\uc5f0\uacb0\ud560 \ub9ac\uc2dc\ubc84\ub97c \uc120\ud0dd\ud558\uae30" diff --git a/homeassistant/components/denonavr/translations/nl.json b/homeassistant/components/denonavr/translations/nl.json index 9f79aebeb60..6a00e03765f 100644 --- a/homeassistant/components/denonavr/translations/nl.json +++ b/homeassistant/components/denonavr/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang" }, "flow_title": "Denon AVR Network Receiver: {name}", "step": { diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 01752f0373f..b3e4cd432ac 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -5,13 +5,13 @@ import schiene import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_OFFSET import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util CONF_DESTINATION = "to" CONF_START = "from" -CONF_OFFSET = "offset" DEFAULT_OFFSET = timedelta(minutes=0) CONF_ONLY_DIRECT = "only_direct" DEFAULT_ONLY_DIRECT = False @@ -87,7 +87,6 @@ class SchieneData: def __init__(self, start, goal, offset, only_direct): """Initialize the sensor.""" - self.start = start self.goal = goal self.offset = offset diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5f60d84f406..b7583d80f82 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -153,7 +153,7 @@ async def async_setup_integration(hass: HomeAssistantType, config: ConfigType) - legacy_platforms = await async_extract_config(hass, config) setup_tasks = [ - legacy_platform.async_setup_legacy(hass, tracker) + asyncio.create_task(legacy_platform.async_setup_legacy(hass, tracker)) for legacy_platform in legacy_platforms ] diff --git a/homeassistant/components/devolo_home_control/translations/ko.json b/homeassistant/components/devolo_home_control/translations/ko.json index 17d4fe28a56..f21122bff70 100644 --- a/homeassistant/components/devolo_home_control/translations/ko.json +++ b/homeassistant/components/devolo_home_control/translations/ko.json @@ -1,15 +1,18 @@ { "config": { "abort": { - "already_configured": "\uc774 Home Control Central \uc720\ub2db\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", - "mydevolo_url": "mydevolo URL", + "home_control_url": "Home Control URL \uc8fc\uc18c", + "mydevolo_url": "mydevolo URL \uc8fc\uc18c", "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc774\uba54\uc77c \uc8fc\uc18c / devolo ID" + "username": "\uc774\uba54\uc77c / devolo ID" } } } diff --git a/homeassistant/components/devolo_home_control/translations/nl.json b/homeassistant/components/devolo_home_control/translations/nl.json index d61f9183cc5..5d79d2ec9e9 100644 --- a/homeassistant/components/devolo_home_control/translations/nl.json +++ b/homeassistant/components/devolo_home_control/translations/nl.json @@ -3,9 +3,14 @@ "abort": { "already_configured": "Account is al geconfigureerd" }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, "step": { "user": { "data": { + "home_control_url": "Home Control URL", + "mydevolo_url": "mydevolo URL", "password": "Wachtwoord", "username": "E-mail adres / devolo ID" } diff --git a/homeassistant/components/devolo_home_control/translations/ru.json b/homeassistant/components/devolo_home_control/translations/ru.json index d4cf639ffd5..b2e82f1355b 100644 --- a/homeassistant/components/devolo_home_control/translations/ru.json +++ b/homeassistant/components/devolo_home_control/translations/ru.json @@ -4,7 +4,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/dexcom/translations/ko.json b/homeassistant/components/dexcom/translations/ko.json index 35129cbfbde..c3daac03356 100644 --- a/homeassistant/components/dexcom/translations/ko.json +++ b/homeassistant/components/dexcom/translations/ko.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/dexcom/translations/ru.json b/homeassistant/components/dexcom/translations/ru.json index 5b6b3ab24b1..aa90d6d998d 100644 --- a/homeassistant/components/dexcom/translations/ru.json +++ b/homeassistant/components/dexcom/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 57e12d03ffe..0bddd5a187e 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_PIN, PERCENTAGE, TEMP_FAHRENHEIT, ) @@ -19,7 +20,6 @@ from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) -CONF_PIN = "pin" CONF_SENSOR = "sensor" CONF_HUMIDITY_OFFSET = "humidity_offset" CONF_TEMPERATURE_OFFSET = "temperature_offset" @@ -56,7 +56,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DHT sensor.""" - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { "AM2302": Adafruit_DHT.AM2302, diff --git a/homeassistant/components/dialogflow/translations/et.json b/homeassistant/components/dialogflow/translations/et.json index 989db1c2564..8ffe23497ef 100644 --- a/homeassistant/components/dialogflow/translations/et.json +++ b/homeassistant/components/dialogflow/translations/et.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Kas oled kindel, et soovid seadistada Dialogflow?", - "title": "Seadistage Dialogflow veebihaak" + "title": "Seadista Dialogflow veebihaak" } } } diff --git a/homeassistant/components/dialogflow/translations/ko.json b/homeassistant/components/dialogflow/translations/ko.json index 7afeb6da74c..2b1be9657b4 100644 --- a/homeassistant/components/dialogflow/translations/ko.json +++ b/homeassistant/components/dialogflow/translations/ko.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow \uc6f9 \ud6c5]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/dialogflow/translations/nl.json b/homeassistant/components/dialogflow/translations/nl.json index 7cccf8ecb9b..82fe7daea00 100644 --- a/homeassistant/components/dialogflow/translations/nl.json +++ b/homeassistant/components/dialogflow/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar de Home Assistant te verzenden, moet u [webhookintegratie van Dialogflow]({dialogflow_url}) instellen. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [de documentatie]({docs_url}) voor verdere informatie." diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index cae0e62b1be..fed13c63dc8 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -79,7 +79,6 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): if discovery_info.get(ATTR_UPNP_SERIAL): receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID- - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": host}}) self.discovery_info.update( diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index c2bec855b9b..2a5e7662d45 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -9,6 +9,7 @@ from homeassistant.components.image_processing import ( CONF_SOURCE, ImageProcessingFaceEntity, ) +from homeassistant.const import ATTR_LOCATION from homeassistant.core import split_entity_id # pylint: disable=unused-import @@ -16,8 +17,6 @@ from homeassistant.components.image_processing import ( # noqa: F401, isort:ski PLATFORM_SCHEMA, ) -ATTR_LOCATION = "location" - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dlib Face detection platform.""" diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 32c2aa5868c..f9db607c298 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -14,12 +14,12 @@ from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingFaceEntity, ) +from homeassistant.const import ATTR_NAME from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_NAME = "name" CONF_FACES = "faces" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index f4180ffcffa..fb2b6daecda 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, ) -from homeassistant.const import CONF_TIMEOUT +from homeassistant.const import CONF_COVERS, CONF_TIMEOUT, CONF_URL from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv @@ -29,12 +29,10 @@ ATTR_SUMMARY = "summary" ATTR_TOTAL_MATCHES = "total_matches" ATTR_PROCESS_TIME = "process_time" -CONF_URL = "url" CONF_AUTH_KEY = "auth_key" CONF_DETECTOR = "detector" CONF_LABELS = "labels" CONF_AREA = "area" -CONF_COVERS = "covers" CONF_TOP = "top" CONF_BOTTOM = "bottom" CONF_RIGHT = "right" diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index ecbcd8563a7..f5d425cb9ef 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -2,6 +2,6 @@ "domain": "doods", "name": "DOODS - Dedicated Open Object Detection Service", "documentation": "https://www.home-assistant.io/integrations/doods", - "requirements": ["pydoods==1.0.2", "pillow==8.1.0"], + "requirements": ["pydoods==1.0.2", "pillow==8.1.1"], "codeowners": [] } diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 1dc5bf56c86..22db3c76273 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -34,6 +34,7 @@ from .const import ( DOOR_STATION_EVENT_ENTITY_IDS, DOOR_STATION_INFO, PLATFORMS, + UNDO_UPDATE_LISTENER, ) from .util import get_doorstation_by_token @@ -128,8 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): device = DoorBird(device_ip, username, password) try: - status = await hass.async_add_executor_job(device.ready) - info = await hass.async_add_executor_job(device.info) + status, info = await hass.async_add_executor_job(_init_doorbird_device, device) except urllib.error.HTTPError as err: if err.code == HTTP_UNAUTHORIZED: _LOGGER.error( @@ -154,18 +154,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): custom_url = doorstation_config.get(CONF_CUSTOM_URL) name = doorstation_config.get(CONF_NAME) events = doorstation_options.get(CONF_EVENTS, []) - doorstation = ConfiguredDoorBird(device, name, events, custom_url, token) + doorstation = ConfiguredDoorBird(device, name, custom_url, token) + doorstation.update_events(events) # Subscribe to doorbell or motion events if not await _async_register_events(hass, doorstation): raise ConfigEntryNotReady + undo_listener = entry.add_update_listener(_update_listener) + hass.data[DOMAIN][config_entry_id] = { DOOR_STATION: doorstation, DOOR_STATION_INFO: info, + UNDO_UPDATE_LISTENER: undo_listener, } - entry.add_update_listener(_update_listener) - for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) @@ -174,9 +176,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True +def _init_doorbird_device(device): + return device.ready(), device.info() + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + unload_ok = all( await asyncio.gather( *[ @@ -195,7 +203,7 @@ async def _async_register_events(hass, doorstation): try: await hass.async_add_executor_job(doorstation.register_events, hass) except HTTPError: - hass.components.persistent_notification.create( + hass.components.persistent_notification.async_create( "Doorbird configuration failed. Please verify that API " "Operator permission is enabled for the Doorbird user. " "A restart will be required once permissions have been " @@ -212,8 +220,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" config_entry_id = entry.entry_id doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] - - doorstation.events = entry.options[CONF_EVENTS] + doorstation.update_events(entry.options[CONF_EVENTS]) # Subscribe to doorbell or motion events await _async_register_events(hass, doorstation) @@ -234,14 +241,19 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi class ConfiguredDoorBird: """Attach additional information to pass along with configured device.""" - def __init__(self, device, name, events, custom_url, token): + def __init__(self, device, name, custom_url, token): """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url + self.events = None + self.doorstation_events = None + self._token = token + + def update_events(self, events): + """Update the doorbird events.""" self.events = events self.doorstation_events = [self._get_event_name(event) for event in self.events] - self._token = token @property def name(self): @@ -305,16 +317,7 @@ class ConfiguredDoorBird: def webhook_is_registered(self, url, favs=None) -> bool: """Return whether the given URL is registered as a device favorite.""" - favs = favs if favs else self.device.favorites() - - if "http" not in favs: - return False - - for fav in favs["http"].values(): - if fav["value"] == url: - return True - - return False + return self.get_webhook_id(url, favs) is not None def get_webhook_id(self, url, favs=None) -> str or None: """ diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 8e3f661254d..4bbd7f8dc86 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -103,7 +103,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if friendly_hostname.endswith(chop_ending): friendly_hostname = friendly_hostname[: -len(chop_ending)] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_NAME: friendly_hostname, CONF_HOST: discovery_info[CONF_HOST], diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index af847dac673..46a95f0d500 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -17,3 +17,5 @@ DOORBIRD_INFO_KEY_DEVICE_TYPE = "DEVICE-TYPE" DOORBIRD_INFO_KEY_RELAYS = "RELAYS" DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR" DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR" + +UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py index f1f146aebb9..424bb79092f 100644 --- a/homeassistant/components/doorbird/switch.py +++ b/homeassistant/components/doorbird/switch.py @@ -17,8 +17,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] config_entry_id = config_entry.entry_id - doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] - doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO] + data = hass.data[DOMAIN][config_entry_id] + doorstation = data[DOOR_STATION] + doorstation_info = data[DOOR_STATION_INFO] relays = doorstation_info["RELAYS"] relays.append(IR_RELAY) diff --git a/homeassistant/components/doorbird/translations/ko.json b/homeassistant/components/doorbird/translations/ko.json index 74057a94d26..819b3b51d10 100644 --- a/homeassistant/components/doorbird/translations/ko.json +++ b/homeassistant/components/doorbird/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc774 DoorBird \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "not_doorbird_device": "\uc774 \uae30\uae30\ub294 DoorBird \uac00 \uc544\ub2d9\ub2c8\ub2e4" }, diff --git a/homeassistant/components/doorbird/translations/ru.json b/homeassistant/components/doorbird/translations/ru.json index 274b88a8b47..5e376ee56d3 100644 --- a/homeassistant/components/doorbird/translations/ru.json +++ b/homeassistant/components/doorbird/translations/ru.json @@ -7,7 +7,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "DoorBird {name} ({host})", diff --git a/homeassistant/components/doorbird/translations/sv.json b/homeassistant/components/doorbird/translations/sv.json index b2a809a576e..546535fb937 100644 --- a/homeassistant/components/doorbird/translations/sv.json +++ b/homeassistant/components/doorbird/translations/sv.json @@ -9,7 +9,8 @@ "host": "V\u00e4rd (IP-adress)", "name": "Enhetsnamn", "username": "Anv\u00e4ndarnamn" - } + }, + "title": "Anslut till DoorBird" } } } diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml index 6e16e00432f..cecb3804227 100644 --- a/homeassistant/components/downloader/services.yaml +++ b/homeassistant/components/downloader/services.yaml @@ -1,15 +1,29 @@ download_file: - description: Downloads a file to the download location. + name: Download file + description: Download a file to the download location. fields: url: + name: URL description: The URL of the file to download. + required: true example: "http://example.org/myfile" + selector: + text: subdir: + name: Subdirectory description: Download into subdirectory. example: "download_dir" + selector: + text: filename: + name: Filename description: Determine the filename. example: "my_file_name" + selector: + text: overwrite: + name: Overwrite description: Whether to overwrite the file or not. - example: "false" + default: false + selector: + boolean: diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index c3f6aa4dea3..c442130bb9f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.25"], + "requirements": ["dsmr_parser==0.28"], "codeowners": ["@Robbie1221"], "config_flow": false } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 78cd317bb3e..aea12a863f0 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -80,35 +80,59 @@ async def async_setup_entry( dsmr_version = config[CONF_DSMR_VERSION] - # Define list of name,obis mappings to generate entities + # Define list of name,obis,force_update mappings to generate entities obis_mapping = [ - ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE], - ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY], - ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF], - ["Energy Consumption (tarif 1)", obis_ref.ELECTRICITY_USED_TARIFF_1], - ["Energy Consumption (tarif 2)", obis_ref.ELECTRICITY_USED_TARIFF_2], - ["Energy Production (tarif 1)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], - ["Energy Production (tarif 2)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2], - ["Power Consumption Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE], - ["Power Consumption Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE], - ["Power Consumption Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE], - ["Power Production Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE], - ["Power Production Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE], - ["Power Production Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE], - ["Short Power Failure Count", obis_ref.SHORT_POWER_FAILURE_COUNT], - ["Long Power Failure Count", obis_ref.LONG_POWER_FAILURE_COUNT], - ["Voltage Sags Phase L1", obis_ref.VOLTAGE_SAG_L1_COUNT], - ["Voltage Sags Phase L2", obis_ref.VOLTAGE_SAG_L2_COUNT], - ["Voltage Sags Phase L3", obis_ref.VOLTAGE_SAG_L3_COUNT], - ["Voltage Swells Phase L1", obis_ref.VOLTAGE_SWELL_L1_COUNT], - ["Voltage Swells Phase L2", obis_ref.VOLTAGE_SWELL_L2_COUNT], - ["Voltage Swells Phase L3", obis_ref.VOLTAGE_SWELL_L3_COUNT], - ["Voltage Phase L1", obis_ref.INSTANTANEOUS_VOLTAGE_L1], - ["Voltage Phase L2", obis_ref.INSTANTANEOUS_VOLTAGE_L2], - ["Voltage Phase L3", obis_ref.INSTANTANEOUS_VOLTAGE_L3], - ["Current Phase L1", obis_ref.INSTANTANEOUS_CURRENT_L1], - ["Current Phase L2", obis_ref.INSTANTANEOUS_CURRENT_L2], - ["Current Phase L3", obis_ref.INSTANTANEOUS_CURRENT_L3], + ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE, True], + ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY, True], + ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF, False], + ["Energy Consumption (tarif 1)", obis_ref.ELECTRICITY_USED_TARIFF_1, True], + ["Energy Consumption (tarif 2)", obis_ref.ELECTRICITY_USED_TARIFF_2, True], + ["Energy Production (tarif 1)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1, True], + ["Energy Production (tarif 2)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2, True], + [ + "Power Consumption Phase L1", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, + False, + ], + [ + "Power Consumption Phase L2", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, + False, + ], + [ + "Power Consumption Phase L3", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, + False, + ], + [ + "Power Production Phase L1", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, + False, + ], + [ + "Power Production Phase L2", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, + False, + ], + [ + "Power Production Phase L3", + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, + False, + ], + ["Short Power Failure Count", obis_ref.SHORT_POWER_FAILURE_COUNT, False], + ["Long Power Failure Count", obis_ref.LONG_POWER_FAILURE_COUNT, False], + ["Voltage Sags Phase L1", obis_ref.VOLTAGE_SAG_L1_COUNT, False], + ["Voltage Sags Phase L2", obis_ref.VOLTAGE_SAG_L2_COUNT, False], + ["Voltage Sags Phase L3", obis_ref.VOLTAGE_SAG_L3_COUNT, False], + ["Voltage Swells Phase L1", obis_ref.VOLTAGE_SWELL_L1_COUNT, False], + ["Voltage Swells Phase L2", obis_ref.VOLTAGE_SWELL_L2_COUNT, False], + ["Voltage Swells Phase L3", obis_ref.VOLTAGE_SWELL_L3_COUNT, False], + ["Voltage Phase L1", obis_ref.INSTANTANEOUS_VOLTAGE_L1, False], + ["Voltage Phase L2", obis_ref.INSTANTANEOUS_VOLTAGE_L2, False], + ["Voltage Phase L3", obis_ref.INSTANTANEOUS_VOLTAGE_L3, False], + ["Current Phase L1", obis_ref.INSTANTANEOUS_CURRENT_L1, False], + ["Current Phase L2", obis_ref.INSTANTANEOUS_CURRENT_L2, False], + ["Current Phase L3", obis_ref.INSTANTANEOUS_CURRENT_L3, False], ] if dsmr_version == "5L": @@ -117,22 +141,26 @@ async def async_setup_entry( [ "Energy Consumption (total)", obis_ref.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, + True, ], [ "Energy Production (total)", obis_ref.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + True, ], ] ) else: obis_mapping.extend( - [["Energy Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL]] + [["Energy Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL, True]] ) # Generate device entities devices = [ - DSMREntity(name, DEVICE_NAME_ENERGY, config[CONF_SERIAL_ID], obis, config) - for name, obis in obis_mapping + DSMREntity( + name, DEVICE_NAME_ENERGY, config[CONF_SERIAL_ID], obis, config, force_update + ) + for name, obis, force_update in obis_mapping ] # Protocol version specific obis @@ -152,6 +180,7 @@ async def async_setup_entry( config[CONF_SERIAL_ID_GAS], gas_obis, config, + True, ), DerivativeDSMREntity( "Hourly Gas Consumption", @@ -159,6 +188,7 @@ async def async_setup_entry( config[CONF_SERIAL_ID_GAS], gas_obis, config, + False, ), ] @@ -185,6 +215,7 @@ async def async_setup_entry( config[CONF_DSMR_VERSION], update_entities_telegram, loop=hass.loop, + keep_alive_interval=60, ) else: reader_factory = partial( @@ -257,7 +288,7 @@ async def async_setup_entry( class DSMREntity(Entity): """Entity reading values from DSMR telegram.""" - def __init__(self, name, device_name, device_serial, obis, config): + def __init__(self, name, device_name, device_serial, obis, config, force_update): """Initialize entity.""" self._name = name self._obis = obis @@ -266,6 +297,7 @@ class DSMREntity(Entity): self._device_name = device_name self._device_serial = device_serial + self._force_update = force_update self._unique_id = f"{device_serial}_{name}".replace(" ", "_") @callback @@ -341,7 +373,7 @@ class DSMREntity(Entity): @property def force_update(self): """Force update.""" - return True + return self._force_update @property def should_poll(self): diff --git a/homeassistant/components/dsmr/translations/ko.json b/homeassistant/components/dsmr/translations/ko.json index 9c8fbbe80a9..17dee71d640 100644 --- a/homeassistant/components/dsmr/translations/ko.json +++ b/homeassistant/components/dsmr/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc7a5\uce58\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" } } } \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/ko.json b/homeassistant/components/dunehd/translations/ko.json index 1ddcadf8350..45a59b4d75b 100644 --- a/homeassistant/components/dunehd/translations/ko.json +++ b/homeassistant/components/dunehd/translations/ko.json @@ -6,7 +6,7 @@ "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index c131ebec3da..e52ec5946b6 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -29,7 +29,6 @@ from .const import ( CONF_CHANNEL, CONF_CHANNEL_COVER, CONF_CLOSE_PRESET, - CONF_DEFAULT, CONF_DEVICE_CLASS, CONF_DURATION, CONF_FADE, @@ -181,7 +180,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: """Set up the Dynalite platform.""" - conf = config.get(DOMAIN) LOGGER.debug("Setting up dynalite component config = %s", conf) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 4159c98f073..3f1e201f3fd 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -19,7 +19,6 @@ CONF_BRIDGES = "bridges" CONF_CHANNEL = "channel" CONF_CHANNEL_COVER = "channel_cover" CONF_CLOSE_PRESET = "close" -CONF_DEFAULT = "default" CONF_DEVICE_CLASS = "class" CONF_DURATION = "duration" CONF_FADE = "fade" diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index b84450c807d..6a85147a2e0 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -4,7 +4,14 @@ from typing import Any, Dict from dynalite_devices_lib import const as dyn_const -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM, CONF_TYPE +from homeassistant.const import ( + CONF_DEFAULT, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_ROOM, + CONF_TYPE, +) from .const import ( ACTIVE_INIT, @@ -16,7 +23,6 @@ from .const import ( CONF_CHANNEL, CONF_CHANNEL_COVER, CONF_CLOSE_PRESET, - CONF_DEFAULT, CONF_DEVICE_CLASS, CONF_DURATION, CONF_FADE, diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index 5e7069ab50b..bb9569358be 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -12,7 +12,6 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" - async_setup_entry_base( hass, config_entry, async_add_entities, "light", DynaliteLight ) diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index d106d976d68..a482228183c 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -12,7 +12,6 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" - async_setup_entry_base( hass, config_entry, async_add_entities, "switch", DynaliteSwitch ) diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py index d23b2b1ef88..3bf4f2bb34c 100644 --- a/homeassistant/components/dyson/air_quality.py +++ b/homeassistant/components/dyson/air_quality.py @@ -1,6 +1,4 @@ """Support for Dyson Pure Cool Air Quality Sensors.""" -import logging - from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State @@ -10,8 +8,6 @@ from . import DYSON_DEVICES, DysonEntity ATTRIBUTION = "Dyson purifier air quality sensor" -_LOGGER = logging.getLogger(__name__) - DYSON_AIQ_DEVICES = "dyson_aiq_devices" ATTR_VOC = "volatile_organic_compounds" diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 6690f77390d..a8e737bb48b 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -1,5 +1,6 @@ """Support for Dyson Pure Cool link fan.""" import logging +import math from typing import Optional from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation @@ -9,15 +10,13 @@ from libpurecool.dyson_pure_state import DysonPureCoolState from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State import voluptuous as vol -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SUPPORT_OSCILLATE, - SUPPORT_SET_SPEED, - FanEntity, -) +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 @@ -70,40 +69,30 @@ SET_DYSON_SPEED_SCHEMA = { } -SPEED_LIST_HA = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] +PRESET_MODE_AUTO = "auto" +PRESET_MODES = [PRESET_MODE_AUTO] -SPEED_LIST_DYSON = [ - int(FanSpeed.FAN_SPEED_1.value), - int(FanSpeed.FAN_SPEED_2.value), - int(FanSpeed.FAN_SPEED_3.value), - int(FanSpeed.FAN_SPEED_4.value), - int(FanSpeed.FAN_SPEED_5.value), - int(FanSpeed.FAN_SPEED_6.value), - int(FanSpeed.FAN_SPEED_7.value), - int(FanSpeed.FAN_SPEED_8.value), - int(FanSpeed.FAN_SPEED_9.value), - int(FanSpeed.FAN_SPEED_10.value), +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_DYSON_TO_HA = { - FanSpeed.FAN_SPEED_1.value: SPEED_LOW, - FanSpeed.FAN_SPEED_2.value: SPEED_LOW, - FanSpeed.FAN_SPEED_3.value: SPEED_LOW, - FanSpeed.FAN_SPEED_4.value: SPEED_LOW, - FanSpeed.FAN_SPEED_AUTO.value: SPEED_MEDIUM, - FanSpeed.FAN_SPEED_5.value: SPEED_MEDIUM, - FanSpeed.FAN_SPEED_6.value: SPEED_MEDIUM, - FanSpeed.FAN_SPEED_7.value: SPEED_MEDIUM, - FanSpeed.FAN_SPEED_8.value: SPEED_HIGH, - FanSpeed.FAN_SPEED_9.value: SPEED_HIGH, - FanSpeed.FAN_SPEED_10.value: SPEED_HIGH, -} +SPEED_LIST_DYSON = list(DYSON_SPEED_TO_INT_VALUE.values()) -SPEED_HA_TO_DYSON = { - SPEED_LOW: FanSpeed.FAN_SPEED_4, - SPEED_MEDIUM: FanSpeed.FAN_SPEED_7, - SPEED_HIGH: FanSpeed.FAN_SPEED_10, -} +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): @@ -160,14 +149,28 @@ class DysonFanEntity(DysonEntity, FanEntity): """Representation of a Dyson fan.""" @property - def speed(self): - """Return the current speed.""" - return SPEED_DYSON_TO_HA[self._device.state.speed] + 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_list(self) -> list: - """Get the list of available speeds.""" - return SPEED_LIST_HA + 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): @@ -206,12 +209,25 @@ class DysonFanEntity(DysonEntity, FanEntity): ATTR_DYSON_SPEED_LIST: self.dyson_speed_list, } - def set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if speed not in SPEED_LIST_HA: - raise ValueError(f'"{speed}" is not a valid speed') - _LOGGER.debug("Set fan speed to: %s", speed) - self.set_dyson_speed(SPEED_HA_TO_DYSON[speed]) + 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.""" @@ -225,6 +241,23 @@ class DysonFanEntity(DysonEntity, FanEntity): speed = FanSpeed(f"{int(dyson_speed):04d}") self.set_dyson_speed(speed) + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = 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.""" @@ -233,15 +266,6 @@ class DysonPureCoolLinkEntity(DysonFanEntity): """Initialize the fan.""" super().__init__(device, DysonPureCoolState) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: - """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed) - if speed is not None: - self.set_speed(speed) - else: - # Speed not set, just turn on - self._device.set_configuration(fan_mode=FanMode.FAN) - def turn_off(self, **kwargs) -> None: """Turn off the fan.""" _LOGGER.debug("Turn off fan %s", self.name) @@ -299,14 +323,22 @@ class DysonPureCoolEntity(DysonFanEntity): """Initialize the fan.""" super().__init__(device, DysonPureCoolV2State) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s", self.name) - - if speed is not None: - self.set_speed(speed) - else: + _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.""" diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index f1198188b5c..80a64e787f0 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -1,6 +1,4 @@ """Support for Dyson Pure Cool Link Sensors.""" -import logging - from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_cool_link import DysonPureCoolLink @@ -58,8 +56,6 @@ SENSOR_NAMES = { DYSON_SENSOR_DEVICES = "dyson_sensor_devices" -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dyson Sensors.""" diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml index 73f7bc75874..f96aa9315c1 100644 --- a/homeassistant/components/dyson/services.yaml +++ b/homeassistant/components/dyson/services.yaml @@ -33,7 +33,7 @@ set_angle: description: The angle at which the oscillation should end example: 255 -flow_direction_front: +set_flow_direction_front: description: Set the fan flow direction. fields: entity_id: diff --git a/homeassistant/components/eafm/translations/ko.json b/homeassistant/components/eafm/translations/ko.json index 4e7bfc9dc93..36af97756ee 100644 --- a/homeassistant/components/eafm/translations/ko.json +++ b/homeassistant/components/eafm/translations/ko.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_stations": "\ud64d\uc218 \ubaa8\ub2c8\ud130\ub9c1 \uc2a4\ud14c\uc774\uc158\uc774 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index c61428cbc78..089f0950854 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -559,7 +559,7 @@ class Thermostat(ClimateEntity): if preset_mode == PRESET_AWAY: self.data.ecobee.set_climate_hold( - self.thermostat_index, "away", "indefinite" + self.thermostat_index, "away", "indefinite", self.hold_hours() ) elif preset_mode == PRESET_TEMPERATURE: @@ -570,6 +570,7 @@ class Thermostat(ClimateEntity): self.thermostat_index, PRESET_TO_ECOBEE_HOLD[preset_mode], self.hold_preference(), + self.hold_hours(), ) elif preset_mode == PRESET_NONE: @@ -585,14 +586,20 @@ class Thermostat(ClimateEntity): if climate_ref is not None: self.data.ecobee.set_climate_hold( - self.thermostat_index, climate_ref, self.hold_preference() + self.thermostat_index, + climate_ref, + self.hold_preference(), + self.hold_hours(), ) else: _LOGGER.warning("Received unknown preset mode: %s", preset_mode) else: self.data.ecobee.set_climate_hold( - self.thermostat_index, preset_mode, self.hold_preference() + self.thermostat_index, + preset_mode, + self.hold_preference(), + self.hold_hours(), ) @property @@ -743,7 +750,7 @@ class Thermostat(ClimateEntity): "useEndTime2hour": 2, "useEndTime4hour": 4, } - return hold_hours_map.get(device_preference, 0) + return hold_hours_map.get(device_preference) def create_vacation(self, service_data): """Create a vacation with user-specified parameters.""" diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 040744b27aa..de7a7d325b3 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -3,6 +3,6 @@ "name": "ecobee", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", - "requirements": ["python-ecobee-api==0.2.8"], + "requirements": ["python-ecobee-api==0.2.10"], "codeowners": ["@marthoc"] } diff --git a/homeassistant/components/ecobee/translations/ko.json b/homeassistant/components/ecobee/translations/ko.json index 8be4c28bfbb..674b087620a 100644 --- a/homeassistant/components/ecobee/translations/ko.json +++ b/homeassistant/components/ecobee/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, "error": { "pin_request_failed": "ecobee \ub85c\ubd80\ud130 PIN \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; API \ud0a4\uac00 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", "token_request_failed": "ecobee \ub85c\ubd80\ud130 \ud1a0\ud070 \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." diff --git a/homeassistant/components/ecobee/translations/nl.json b/homeassistant/components/ecobee/translations/nl.json index 62405b05ff1..957d2f8244d 100644 --- a/homeassistant/components/ecobee/translations/nl.json +++ b/homeassistant/components/ecobee/translations/nl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, "error": { "pin_request_failed": "Fout bij het aanvragen van pincode bij ecobee; Controleer of de API-sleutel correct is.", "token_request_failed": "Fout bij het aanvragen van tokens bij ecobee; probeer het opnieuw." diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index ec8131c5105..b87e6bb0cd0 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -1,6 +1,4 @@ """Support for Rheem EcoNet water heaters.""" -import logging - from pyeconet.equipment import EquipmentType from homeassistant.components.binary_sensor import ( @@ -12,8 +10,6 @@ from homeassistant.components.binary_sensor import ( from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT -_LOGGER = logging.getLogger(__name__) - SENSOR_NAME_RUNNING = "running" SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" SENSOR_NAME_VACATION = "vacation" diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 6ae14d18aa1..e0ef7dc6ce9 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -1,6 +1,4 @@ """Support for Rheem EcoNet water heaters.""" -import logging - from pyeconet.equipment import EquipmentType from homeassistant.const import ( @@ -25,9 +23,6 @@ WIFI_SIGNAL = "wifi_signal" RUNNING_STATE = "running_state" -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet sensor based on a config entry.""" equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] diff --git a/homeassistant/components/econet/translations/cs.json b/homeassistant/components/econet/translations/cs.json new file mode 100644 index 00000000000..bd8b0799628 --- /dev/null +++ b/homeassistant/components/econet/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/de.json b/homeassistant/components/econet/translations/de.json new file mode 100644 index 00000000000..854d61f1790 --- /dev/null +++ b/homeassistant/components/econet/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/es.json b/homeassistant/components/econet/translations/es.json index ac69f8f7be1..8634be9413f 100644 --- a/homeassistant/components/econet/translations/es.json +++ b/homeassistant/components/econet/translations/es.json @@ -14,7 +14,8 @@ "data": { "email": "Correo electr\u00f3nico", "password": "Contrase\u00f1a" - } + }, + "title": "Configurar la cuenta Rheem EcoNet" } } } diff --git a/homeassistant/components/econet/translations/fr.json b/homeassistant/components/econet/translations/fr.json new file mode 100644 index 00000000000..64fd39c852a --- /dev/null +++ b/homeassistant/components/econet/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "cannot_connect": "\u00c9chec de la connexion ", + "invalid_auth": "Authentification invalide " + }, + "error": { + "cannot_connect": "\u00c9chec de la connexion", + "invalid_auth": "Authentification invalide " + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + }, + "title": "Configurer le compte Rheem EcoNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/ko.json b/homeassistant/components/econet/translations/ko.json new file mode 100644 index 00000000000..f5c1381b8b1 --- /dev/null +++ b/homeassistant/components/econet/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "email": "\uc774\uba54\uc77c", + "password": "\ube44\ubc00\ubc88\ud638" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/nl.json b/homeassistant/components/econet/translations/nl.json new file mode 100644 index 00000000000..226c1611e2b --- /dev/null +++ b/homeassistant/components/econet/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Stel Rheem EcoNet-account in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/ru.json b/homeassistant/components/econet/translations/ru.json index 109ded8db99..1b0d79ac396 100644 --- a/homeassistant/components/econet/translations/ru.json +++ b/homeassistant/components/econet/translations/ru.json @@ -3,11 +3,11 @@ "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.", "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 8c16317beda..02bc8fa0ccb 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -5,7 +5,13 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_CURRENCY, ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ( + CONF_CURRENCY, + CONF_MONITORED_VARIABLES, + CONF_TYPE, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -14,8 +20,6 @@ _RESOURCE = "https://engage.efergy.com/mobile_proxy/" CONF_APPTOKEN = "app_token" CONF_UTC_OFFSET = "utc_offset" -CONF_MONITORED_VARIABLES = "monitored_variables" -CONF_SENSOR_TYPE = "type" CONF_PERIOD = "period" @@ -40,7 +44,7 @@ TYPES_SCHEMA = vol.In(SENSOR_TYPES) SENSORS_SCHEMA = vol.Schema( { - vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Required(CONF_TYPE): TYPES_SCHEMA, vol.Optional(CONF_CURRENCY, default=""): cv.string, vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.string, } @@ -62,14 +66,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for variable in config[CONF_MONITORED_VARIABLES]: - if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: + if variable[CONF_TYPE] == CONF_CURRENT_VALUES: url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor["sid"] dev.append( EfergySensor( - variable[CONF_SENSOR_TYPE], + variable[CONF_TYPE], app_token, utc_offset, variable[CONF_PERIOD], @@ -79,7 +83,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) dev.append( EfergySensor( - variable[CONF_SENSOR_TYPE], + variable[CONF_TYPE], app_token, utc_offset, variable[CONF_PERIOD], diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index a6f25b8827c..e9138afd86c 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -1,13 +1,15 @@ """Config flow to configure the Elgato Key Light integration.""" -from typing import Any, Dict, Optional +from __future__ import annotations -from elgato import Elgato, ElgatoError, Info +from typing import Any, Dict + +from elgato import Elgato, ElgatoError import voluptuous as vol from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import CONF_SERIAL_NUMBER, DOMAIN # pylint: disable=unused-import @@ -18,93 +20,54 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + host: str + port: int + serial_number: str + async def async_step_user( - self, user_input: Optional[ConfigType] = None + self, user_input: Dict[str, Any] | None = None ) -> Dict[str, Any]: """Handle a flow initiated by the user.""" if user_input is None: - return self._show_setup_form() + return self._async_show_setup_form() + + self.host = user_input[CONF_HOST] + self.port = user_input[CONF_PORT] try: - info = await self._get_elgato_info( - user_input[CONF_HOST], user_input[CONF_PORT] - ) + await self._get_elgato_serial_number(raise_on_progress=False) except ElgatoError: - return self._show_setup_form({"base": "cannot_connect"}) + return self._async_show_setup_form({"base": "cannot_connect"}) - # Check if already configured - await self.async_set_unique_id(info.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=info.serial_number, - data={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - CONF_SERIAL_NUMBER: info.serial_number, - }, - ) + return self._async_create_entry() async def async_step_zeroconf( - self, user_input: Optional[ConfigType] = None + self, discovery_info: Dict[str, Any] ) -> Dict[str, Any]: """Handle zeroconf discovery.""" - if user_input is None: - return self.async_abort(reason="cannot_connect") + self.host = discovery_info[CONF_HOST] + self.port = discovery_info[CONF_PORT] try: - info = await self._get_elgato_info( - user_input[CONF_HOST], user_input[CONF_PORT] - ) + await self._get_elgato_serial_number() except ElgatoError: return self.async_abort(reason="cannot_connect") - # Check if already configured - await self.async_set_unique_id(info.serial_number) - self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) - - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - self.context.update( - { - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - CONF_SERIAL_NUMBER: info.serial_number, - "title_placeholders": {"serial_number": info.serial_number}, - } + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"serial_number": self.serial_number}, ) - # Prepare configuration flow - return self._show_confirm_dialog() - - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 async def async_step_zeroconf_confirm( - self, user_input: ConfigType = None + self, _: Dict[str, Any] | None = None ) -> Dict[str, Any]: """Handle a flow initiated by zeroconf.""" - if user_input is None: - return self._show_confirm_dialog() + return self._async_create_entry() - try: - info = await self._get_elgato_info( - self.context.get(CONF_HOST), self.context.get(CONF_PORT) - ) - except ElgatoError: - return self.async_abort(reason="cannot_connect") - - # Check if already configured - await self.async_set_unique_id(info.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=self.context.get(CONF_SERIAL_NUMBER), - data={ - CONF_HOST: self.context.get(CONF_HOST), - CONF_PORT: self.context.get(CONF_PORT), - CONF_SERIAL_NUMBER: self.context.get(CONF_SERIAL_NUMBER), - }, - ) - - def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + @callback + def _async_show_setup_form( + self, errors: Dict[str, str] | None = None + ) -> Dict[str, Any]: """Show the setup form to the user.""" return self.async_show_form( step_id="user", @@ -117,21 +80,33 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - def _show_confirm_dialog(self) -> Dict[str, Any]: - """Show the confirm dialog to the user.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - serial_number = self.context.get(CONF_SERIAL_NUMBER) - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders={"serial_number": serial_number}, + @callback + def _async_create_entry(self) -> Dict[str, Any]: + return self.async_create_entry( + title=self.serial_number, + data={ + CONF_HOST: self.host, + CONF_PORT: self.port, + CONF_SERIAL_NUMBER: self.serial_number, + }, ) - async def _get_elgato_info(self, host: str, port: int) -> Info: + async def _get_elgato_serial_number(self, raise_on_progress: bool = True) -> None: """Get device information from an Elgato Key Light device.""" session = async_get_clientsession(self.hass) elgato = Elgato( - host, - port=port, + host=self.host, + port=self.port, session=session, ) - return await elgato.info() + info = await elgato.info() + + # Check if already configured + await self.async_set_unique_id( + info.serial_number, raise_on_progress=raise_on_progress + ) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.host, CONF_PORT: self.port} + ) + + self.serial_number = info.serial_number diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index 2b6caa37a8f..b2535ce0e4f 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -12,6 +12,5 @@ ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" ATTR_ON = "on" ATTR_SOFTWARE_VERSION = "sw_version" -ATTR_TEMPERATURE = "temperature" CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 313b5600248..0648a4817bc 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -1,7 +1,9 @@ """Support for LED lights.""" +from __future__ import annotations + from datetime import timedelta import logging -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List from elgato import Elgato, ElgatoError, Info, State @@ -13,7 +15,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, ATTR_TEMPERATURE from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType @@ -23,7 +25,6 @@ from .const import ( ATTR_MODEL, ATTR_ON, ATTR_SOFTWARE_VERSION, - ATTR_TEMPERATURE, DATA_ELGATO_CLIENT, DOMAIN, ) @@ -42,7 +43,7 @@ async def async_setup_entry( """Set up Elgato Key Light based on a config entry.""" elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] info = await elgato.info() - async_add_entities([ElgatoLight(entry.entry_id, elgato, info)], True) + async_add_entities([ElgatoLight(elgato, info)], True) class ElgatoLight(LightEntity): @@ -50,15 +51,14 @@ class ElgatoLight(LightEntity): def __init__( self, - entry_id: str, elgato: Elgato, info: Info, ): """Initialize Elgato Key Light.""" - self._brightness: Optional[int] = None + self._brightness: int | None = None self._info: Info = info - self._state: Optional[bool] = None - self._temperature: Optional[int] = None + self._state: bool | None = None + self._temperature: int | None = None self._available = True self.elgato = elgato @@ -81,22 +81,22 @@ class ElgatoLight(LightEntity): return self._info.serial_number @property - def brightness(self) -> Optional[int]: + def brightness(self) -> int | None: """Return the brightness of this light between 1..255.""" return self._brightness @property - def color_temp(self): + def color_temp(self) -> int | None: """Return the CT color value in mireds.""" return self._temperature @property - def min_mireds(self): + def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return 143 @property - def max_mireds(self): + def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return 344 @@ -116,9 +116,8 @@ class ElgatoLight(LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - data = {} + data: Dict[str, bool | int] = {ATTR_ON: True} - data[ATTR_ON] = True if ATTR_ON in kwargs: data[ATTR_ON] = kwargs[ATTR_ON] diff --git a/homeassistant/components/elgato/translations/ko.json b/homeassistant/components/elgato/translations/ko.json index d11b106e28b..f2deb818431 100644 --- a/homeassistant/components/elgato/translations/ko.json +++ b/homeassistant/components/elgato/translations/ko.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "Elgato Key Light \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Elgato Key Light: {serial_number}", "step": { diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index e33e1722edf..d50c5d65d90 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, + CONF_PREFIX, CONF_TEMPERATURE_UNIT, CONF_USERNAME, TEMP_CELSIUS, @@ -38,7 +39,6 @@ from .const import ( CONF_KEYPAD, CONF_OUTPUT, CONF_PLC, - CONF_PREFIX, CONF_SETTING, CONF_TASK, CONF_THERMOSTAT, @@ -197,7 +197,6 @@ def _async_find_matching_config_entry(hass, prefix): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Elk-M1 Control from a config entry.""" - conf = entry.data _LOGGER.debug("Setting up elkm1 %s", conf["host"]) diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index 0248025795b..b72cfa19335 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, CONF_PASSWORD, + CONF_PREFIX, CONF_PROTOCOL, CONF_TEMPERATURE_UNIT, CONF_USERNAME, @@ -20,7 +21,7 @@ from homeassistant.const import ( from homeassistant.util import slugify from . import async_wait_for_elk_to_sync -from .const import CONF_AUTO_CONFIGURE, CONF_PREFIX +from .const import CONF_AUTO_CONFIGURE from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -50,7 +51,6 @@ async def validate_input(data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - userid = data.get(CONF_USERNAME) password = data.get(CONF_PASSWORD) diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py index 71646582c99..4d2dac4b1de 100644 --- a/homeassistant/components/elkm1/const.py +++ b/homeassistant/components/elkm1/const.py @@ -3,7 +3,7 @@ from elkm1_lib.const import Max import voluptuous as vol -from homeassistant.const import ATTR_CODE +from homeassistant.const import ATTR_CODE, CONF_ZONE DOMAIN = "elkm1" @@ -17,8 +17,6 @@ CONF_PLC = "plc" CONF_SETTING = "setting" CONF_TASK = "task" CONF_THERMOSTAT = "thermostat" -CONF_ZONE = "zone" -CONF_PREFIX = "prefix" BARE_TEMP_FAHRENHEIT = "F" diff --git a/homeassistant/components/elkm1/translations/ko.json b/homeassistant/components/elkm1/translations/ko.json index d074946b7ad..fb8c22ba5b2 100644 --- a/homeassistant/components/elkm1/translations/ko.json +++ b/homeassistant/components/elkm1/translations/ko.json @@ -5,7 +5,7 @@ "already_configured": "\uc774 \uc811\ub450\uc0ac\ub85c ElkM1 \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/elkm1/translations/ru.json b/homeassistant/components/elkm1/translations/ru.json index 3c84b98b0ca..48a950f1cca 100644 --- a/homeassistant/components/elkm1/translations/ru.json +++ b/homeassistant/components/elkm1/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index b4a49c7efcd..11ad80688a3 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -5,7 +5,12 @@ from aiohttp import web import voluptuous as vol from homeassistant import util -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_ENTITIES, + CONF_TYPE, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json @@ -31,7 +36,6 @@ NUMBERS_FILE = "emulated_hue_ids.json" CONF_ADVERTISE_IP = "advertise_ip" CONF_ADVERTISE_PORT = "advertise_port" -CONF_ENTITIES = "entities" CONF_ENTITY_HIDDEN = "hidden" CONF_ENTITY_NAME = "name" CONF_EXPOSE_BY_DEFAULT = "expose_by_default" @@ -40,7 +44,6 @@ CONF_HOST_IP = "host_ip" CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable" CONF_LISTEN_PORT = "listen_port" CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" -CONF_TYPE = "type" CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" TYPE_ALEXA = "alexa" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 51580a28adf..1630405a73e 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -43,9 +43,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_TRANSITION, + ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, ) from homeassistant.components.media_player.const import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -82,6 +85,8 @@ STATE_COLORMODE = "colormode" STATE_HUE = "hue" STATE_SATURATION = "sat" STATE_COLOR_TEMP = "ct" +STATE_TRANSITON = "tt" +STATE_XY = "xy" # Hue API states, defined separately in case they change HUE_API_STATE_ON = "on" @@ -90,7 +95,9 @@ HUE_API_STATE_COLORMODE = "colormode" HUE_API_STATE_HUE = "hue" HUE_API_STATE_SAT = "sat" HUE_API_STATE_CT = "ct" +HUE_API_STATE_XY = "xy" HUE_API_STATE_EFFECT = "effect" +HUE_API_STATE_TRANSITION = "transitiontime" # Hue API min/max values - https://developers.meethue.com/develop/hue-api/lights-api/ HUE_API_STATE_BRI_MIN = 1 # Brightness @@ -357,6 +364,8 @@ class HueOneLightChangeView(HomeAssistantView): STATE_HUE: None, STATE_SATURATION: None, STATE_COLOR_TEMP: None, + STATE_XY: None, + STATE_TRANSITON: None, } if HUE_API_STATE_ON in request_json: @@ -372,6 +381,7 @@ class HueOneLightChangeView(HomeAssistantView): (HUE_API_STATE_HUE, STATE_HUE), (HUE_API_STATE_SAT, STATE_SATURATION), (HUE_API_STATE_CT, STATE_COLOR_TEMP), + (HUE_API_STATE_TRANSITION, STATE_TRANSITON), ): if key in request_json: try: @@ -379,6 +389,17 @@ class HueOneLightChangeView(HomeAssistantView): except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) return self.json_message("Bad request", HTTP_BAD_REQUEST) + if HUE_API_STATE_XY in request_json: + try: + parsed[STATE_XY] = tuple( + ( + float(request_json[HUE_API_STATE_XY][0]), + float(request_json[HUE_API_STATE_XY][1]), + ) + ) + except ValueError: + _LOGGER.error("Unable to parse data (2): %s", request_json) + return self.json_message("Bad request", HTTP_BAD_REQUEST) if HUE_API_STATE_BRI in request_json: if entity.domain == light.DOMAIN: @@ -444,10 +465,17 @@ class HueOneLightChangeView(HomeAssistantView): data[ATTR_HS_COLOR] = (hue, sat) + if parsed[STATE_XY] is not None: + data[ATTR_XY_COLOR] = parsed[STATE_XY] + if entity_features & SUPPORT_COLOR_TEMP: if parsed[STATE_COLOR_TEMP] is not None: data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP] + if entity_features & SUPPORT_TRANSITION: + if parsed[STATE_TRANSITON] is not None: + data[ATTR_TRANSITION] = parsed[STATE_TRANSITON] / 10 + # If the requested entity is a script, add some variables elif entity.domain == script.DOMAIN: data["variables"] = { @@ -557,6 +585,8 @@ class HueOneLightChangeView(HomeAssistantView): (STATE_HUE, HUE_API_STATE_HUE), (STATE_SATURATION, HUE_API_STATE_SAT), (STATE_COLOR_TEMP, HUE_API_STATE_CT), + (STATE_XY, HUE_API_STATE_XY), + (STATE_TRANSITON, HUE_API_STATE_TRANSITION), ): if parsed[key] is not None: json_response.append( diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 58f964d4984..8ff7eb85b39 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -63,7 +63,6 @@ def create_upnp_datagram_endpoint( advertise_port, ): """Create the UPNP socket and protocol.""" - # Listen for UDP port 1900 packets sent to SSDP multicast address ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) ssdp_socket.setblocking(False) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index c3458093943..bb292b2e7b5 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.8.1"], + "requirements": ["sense_energy==0.9.0"], "codeowners": ["@kbickar"], "quality_scale": "internal" } diff --git a/homeassistant/components/emulated_roku/translations/ko.json b/homeassistant/components/emulated_roku/translations/ko.json index e9d1d134af2..32b4202bc78 100644 --- a/homeassistant/components/emulated_roku/translations/ko.json +++ b/homeassistant/components/emulated_roku/translations/ko.json @@ -1,11 +1,14 @@ { "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { - "advertise_ip": "\uad11\uace0 IP", + "advertise_ip": "\uad11\uace0 IP \uc8fc\uc18c", "advertise_port": "\uad11\uace0 \ud3ec\ud2b8", - "host_ip": "\ud638\uc2a4\ud2b8 IP", + "host_ip": "\ud638\uc2a4\ud2b8 IP \uc8fc\uc18c", "listen_port": "\uc218\uc2e0 \ud3ec\ud2b8", "name": "\uc774\ub984", "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ud560\ub2f9 (\ucc38/\uac70\uc9d3)" diff --git a/homeassistant/components/enocean/translations/nl.json b/homeassistant/components/enocean/translations/nl.json new file mode 100644 index 00000000000..79aaec23123 --- /dev/null +++ b/homeassistant/components/enocean/translations/nl.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index f4fa96b52d6..dd2252a585f 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -184,23 +184,34 @@ def get_forecast(ec_data, forecast_type): if forecast_type == "daily": half_days = ec_data.daily_forecasts + + today = { + ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[0]["icon_code"]) + ), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: int( + half_days[0]["precip_probability"] + ), + } + if half_days[0]["temperature_class"] == "high": - forecast_array.append( + today.update( { - ATTR_FORECAST_TIME: dt.now().isoformat(), ATTR_FORECAST_TEMP: int(half_days[0]["temperature"]), ATTR_FORECAST_TEMP_LOW: int(half_days[1]["temperature"]), - ATTR_FORECAST_CONDITION: icon_code_to_condition( - int(half_days[0]["icon_code"]) - ), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: int( - half_days[0]["precip_probability"] - ), } ) - half_days = half_days[2:] else: - half_days = half_days[1:] + today.update( + { + ATTR_FORECAST_TEMP_LOW: int(half_days[0]["temperature"]), + ATTR_FORECAST_TEMP: int(half_days[1]["temperature"]), + } + ) + + forecast_array.append(today) + half_days = half_days[2:] for day, high, low in zip(range(1, 6), range(0, 9, 2), range(1, 10, 2)): forecast_array.append( diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 636cf0c19df..73e20eea92c 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -5,7 +5,12 @@ import logging from pyenvisalink import EnvisalinkAlarmPanel import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_CODE, + CONF_HOST, + CONF_TIMEOUT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -18,7 +23,6 @@ DOMAIN = "envisalink" DATA_EVL = "envisalink" -CONF_CODE = "code" CONF_EVL_KEEPALIVE = "keepalive_interval" CONF_EVL_PORT = "port" CONF_EVL_VERSION = "evl_version" @@ -99,7 +103,6 @@ SERVICE_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up for Envisalink devices.""" - conf = config.get(DOMAIN) host = conf.get(CONF_HOST) diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 670dc78392f..dff434a68ee 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -15,6 +15,7 @@ from homeassistant.components.alarm_control_panel.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, @@ -28,7 +29,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( - CONF_CODE, CONF_PANIC, CONF_PARTITIONNAME, DATA_EVL, diff --git a/homeassistant/components/epson/translations/ko.json b/homeassistant/components/epson/translations/ko.json new file mode 100644 index 00000000000..1ee9afdcf75 --- /dev/null +++ b/homeassistant/components/epson/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "port": "\ud3ec\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/nl.json b/homeassistant/components/epson/translations/nl.json new file mode 100644 index 00000000000..d5ae90c0e38 --- /dev/null +++ b/homeassistant/components/epson/translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam", + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index c0c3d02ec56..6ce411b5169 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,5 +1,6 @@ """Support for esphome devices.""" import asyncio +import functools import logging import math from typing import Any, Callable, Dict, List, Optional @@ -39,8 +40,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, HomeAssistantType # Import config flow so that it's added to the registry -from .config_flow import EsphomeFlowHandler # noqa: F401 -from .entry_data import DATA_KEY, RuntimeEntryData +from .entry_data import RuntimeEntryData DOMAIN = "esphome" _LOGGER = logging.getLogger(__name__) @@ -52,16 +52,13 @@ CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: - """Stub to allow setting up this component. - - Configuration through YAML is not supported at this time. - """ + """Stub to allow setting up this component.""" return True async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up the esphome component.""" - hass.data.setdefault(DATA_KEY, {}) + hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -83,7 +80,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool store = Store( hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder ) - entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( + entry_data = hass.data[DOMAIN][entry.entry_id] = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=store ) @@ -362,7 +359,7 @@ async def _cleanup_instance( hass: HomeAssistantType, entry: ConfigEntry ) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" - data: RuntimeEntryData = hass.data[DATA_KEY].pop(entry.entry_id) + data: RuntimeEntryData = hass.data[DOMAIN].pop(entry.entry_id) if data.reconnect_task is not None: data.reconnect_task.cancel() for disconnect_cb in data.disconnect_callbacks: @@ -520,7 +517,7 @@ class EsphomeBaseEntity(Entity): f"esphome_{self._entry_id}_remove_" f"{self._component_key}_{self._key}" ), - self.async_remove, + functools.partial(self.async_remove, force_remove=True), ) ) @@ -544,7 +541,7 @@ class EsphomeBaseEntity(Entity): @property def _entry_data(self) -> RuntimeEntryData: - return self.hass.data[DATA_KEY][self._entry_id] + return self.hass.data[DOMAIN][self._entry_id] @property def _static_info(self) -> EntityInfo: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 1d1fc421bb8..a84aa2959ea 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -11,9 +11,8 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .entry_data import DATA_KEY, RuntimeEntryData - -DOMAIN = "esphome" +from . import DOMAIN +from .entry_data import RuntimeEntryData class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -49,12 +48,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @property def _name(self): - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 return self.context.get(CONF_NAME) @_name.setter def _name(self, value): - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context[CONF_NAME] = value self.context["title_placeholders"] = {"name": self._name} @@ -107,9 +104,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ]: # Is this address or IP address already configured? already_configured = True - elif entry.entry_id in self.hass.data.get(DATA_KEY, {}): + elif entry.entry_id in self.hass.data.get(DOMAIN, {}): # Does a config entry with this name already exist? - data: RuntimeEntryData = self.hass.data[DATA_KEY][entry.entry_id] + data: RuntimeEntryData = self.hass.data[DOMAIN][entry.entry_id] # Node names are unique in the network if data.device_info is not None: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 54da1ed5562..58b20d18e12 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,8 +29,6 @@ from homeassistant.helpers.typing import HomeAssistantType if TYPE_CHECKING: from . import APIClient -DATA_KEY = "esphome" - SAVE_DELAY = 120 # Mapping from ESPHome info type to HA platform diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 32f445d23a2..df23f37cb63 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -1,15 +1,11 @@ """Support for ESPHome fans.""" -from typing import List, Optional +from typing import Optional from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, @@ -17,6 +13,10 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from . import ( EsphomeEntity, @@ -25,6 +25,8 @@ from . import ( platform_async_setup_entry, ) +ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities @@ -41,15 +43,6 @@ async def async_setup_entry( ) -@esphome_map_enum -def _fan_speeds(): - return { - FanSpeed.LOW: SPEED_LOW, - FanSpeed.MEDIUM: SPEED_MEDIUM, - FanSpeed.HIGH: SPEED_HIGH, - } - - @esphome_map_enum def _fan_directions(): return { @@ -69,26 +62,30 @@ class EsphomeFan(EsphomeEntity, FanEntity): def _state(self) -> Optional[FanState]: return super()._state - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if speed == SPEED_OFF: + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: await self.async_turn_off() return - await self._client.fan_command( - self._static_info.key, speed=_fan_speeds.from_hass(speed) - ) - - async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: - """Turn on the fan.""" - if speed == SPEED_OFF: - await self.async_turn_off() - return data = {"key": self._static_info.key, "state": True} - if speed is not None: - data["speed"] = _fan_speeds.from_hass(speed) + if percentage is not None: + named_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + data["speed"] = named_speed await self._client.fan_command(**data) + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + await self.async_set_percentage(percentage) + async def async_turn_off(self, **kwargs) -> None: """Turn off the fan.""" await self._client.fan_command(key=self._static_info.key, state=False) @@ -114,11 +111,18 @@ class EsphomeFan(EsphomeEntity, FanEntity): return self._state.state @esphome_state_property - def speed(self) -> Optional[str]: - """Return the current speed.""" + def percentage(self) -> Optional[str]: + """Return the current speed percentage.""" if not self._static_info.supports_speed: return None - return _fan_speeds.from_esphome(self._state.speed) + return ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, self._state.speed + ) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) @esphome_state_property def oscillating(self) -> None: @@ -134,13 +138,6 @@ class EsphomeFan(EsphomeEntity, FanEntity): return None return _fan_directions.from_esphome(self._state.direction) - @property - def speed_list(self) -> Optional[List[str]]: - """Get the list of available speeds.""" - if not self._static_info.supports_speed: - return None - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c69f4f4d8c6..17ff2ca96ba 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==2.6.4"], + "requirements": ["aioesphomeapi==2.6.5"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter"], "after_dependencies": ["zeroconf", "tag"] diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index fbf3925953b..fe9922cf0ed 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -3,8 +3,11 @@ import math from typing import Optional from aioesphomeapi import SensorInfo, SensorState, TextSensorInfo, TextSensorState +import voluptuous as vol +from homeassistant.components.sensor import DEVICE_CLASSES from homeassistant.config_entries import ConfigEntry +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -52,7 +55,9 @@ class EsphomeSensor(EsphomeEntity): @property def icon(self) -> str: """Return the icon.""" - return self._static_info.icon + if not self._static_info.icon or self._static_info.device_class: + return None + return vol.Schema(cv.icon)(self._static_info.icon) @property def force_update(self) -> bool: @@ -71,18 +76,27 @@ class EsphomeSensor(EsphomeEntity): @property def unit_of_measurement(self) -> str: """Return the unit the value is expressed in.""" + if not self._static_info.unit_of_measurement: + return None return self._static_info.unit_of_measurement + @property + def device_class(self) -> str: + """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 + class EsphomeTextSensor(EsphomeEntity): """A text sensor implementation for ESPHome.""" @property - def _static_info(self) -> "TextSensorInfo": + def _static_info(self) -> TextSensorInfo: return super()._static_info @property - def _state(self) -> Optional["TextSensorState"]: + def _state(self) -> Optional[TextSensorState]: return super()._state @property diff --git a/homeassistant/components/esphome/translations/ko.json b/homeassistant/components/esphome/translations/ko.json index 82b9490757b..18827f69024 100644 --- a/homeassistant/components/esphome/translations/ko.json +++ b/homeassistant/components/esphome/translations/ko.json @@ -1,11 +1,12 @@ { "config": { "abort": { - "already_configured": "ESP \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "ESP \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4" }, "error": { "connection_error": "ESP \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. YAML \ud30c\uc77c\uc5d0 'api:' \ub97c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "resolve_error": "ESP \uc758 \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uace0\uc815 IP \uc8fc\uc18c\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", diff --git a/homeassistant/components/esphome/translations/ru.json b/homeassistant/components/esphome/translations/ru.json index bcbe9148854..4277a057a86 100644 --- a/homeassistant/components/esphome/translations/ru.json +++ b/homeassistant/components/esphome/translations/ru.json @@ -6,7 +6,7 @@ }, "error": { "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips." }, "flow_title": "ESPHome: {name}", diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py new file mode 100644 index 00000000000..b9def765123 --- /dev/null +++ b/homeassistant/components/faa_delays/__init__.py @@ -0,0 +1,84 @@ +"""The FAA Delays integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from faadelays import Airport + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the FAA Delays component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up FAA Delays from a config entry.""" + code = entry.data[CONF_ID] + + coordinator = FAADataUpdateCoordinator(hass, code) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class FAADataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching FAA API data from a single endpoint.""" + + def __init__(self, hass, code): + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) + ) + self.session = aiohttp_client.async_get_clientsession(hass) + self.data = Airport(code, self.session) + self.code = code + + async def _async_update_data(self): + try: + with timeout(10): + await self.data.update() + except ClientConnectionError as err: + raise UpdateFailed(err) from err + return self.data diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py new file mode 100644 index 00000000000..6c5876b7017 --- /dev/null +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -0,0 +1,93 @@ +"""Platform for FAA Delays sensor component.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, FAA_BINARY_SENSORS + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a FAA sensor based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + binary_sensors = [] + for kind, attrs in FAA_BINARY_SENSORS.items(): + name = attrs[ATTR_NAME] + icon = attrs[ATTR_ICON] + + binary_sensors.append( + FAABinarySensor(coordinator, kind, name, icon, entry.entry_id) + ) + + async_add_entities(binary_sensors) + + +class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): + """Define a binary sensor for FAA Delays.""" + + def __init__(self, coordinator, sensor_type, name, icon, entry_id): + """Initialize the sensor.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._entry_id = entry_id + self._icon = icon + self._name = name + self._sensor_type = sensor_type + self._id = self.coordinator.data.iata + self._attrs = {} + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._id} {self._name}" + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + if self._sensor_type == "GROUND_DELAY": + return self.coordinator.data.ground_delay.status + if self._sensor_type == "GROUND_STOP": + return self.coordinator.data.ground_stop.status + if self._sensor_type == "DEPART_DELAY": + return self.coordinator.data.depart_delay.status + if self._sensor_type == "ARRIVE_DELAY": + return self.coordinator.data.arrive_delay.status + if self._sensor_type == "CLOSURE": + return self.coordinator.data.closure.status + return None + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self._id}_{self._sensor_type}" + + @property + def device_state_attributes(self): + """Return attributes for sensor.""" + if self._sensor_type == "GROUND_DELAY": + self._attrs["average"] = self.coordinator.data.ground_delay.average + self._attrs["reason"] = self.coordinator.data.ground_delay.reason + elif self._sensor_type == "GROUND_STOP": + self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime + self._attrs["reason"] = self.coordinator.data.ground_stop.reason + elif self._sensor_type == "DEPART_DELAY": + self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum + self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum + self._attrs["trend"] = self.coordinator.data.depart_delay.trend + self._attrs["reason"] = self.coordinator.data.depart_delay.reason + elif self._sensor_type == "ARRIVE_DELAY": + self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum + self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum + self._attrs["trend"] = self.coordinator.data.arrive_delay.trend + self._attrs["reason"] = self.coordinator.data.arrive_delay.reason + elif self._sensor_type == "CLOSURE": + self._attrs["begin"] = self.coordinator.data.closure.begin + self._attrs["end"] = self.coordinator.data.closure.end + self._attrs["reason"] = self.coordinator.data.closure.reason + return self._attrs diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py new file mode 100644 index 00000000000..46d917cc92f --- /dev/null +++ b/homeassistant/components/faa_delays/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for FAA Delays integration.""" +import logging + +from aiohttp import ClientConnectionError +import faadelays +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_ID): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for FAA Delays.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + websession = aiohttp_client.async_get_clientsession(self.hass) + + data = faadelays.Airport(user_input[CONF_ID], websession) + + try: + await data.update() + + except faadelays.InvalidAirport: + _LOGGER.error("Airport code %s is invalid", user_input[CONF_ID]) + errors[CONF_ID] = "invalid_airport" + + except ClientConnectionError: + _LOGGER.error("Error connecting to FAA API") + errors["base"] = "cannot_connect" + + except Exception as error: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", error) + errors["base"] = "unknown" + + if not errors: + _LOGGER.debug( + "Creating entry with id: %s, name: %s", + user_input[CONF_ID], + data.name, + ) + return self.async_create_entry(title=data.name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py new file mode 100644 index 00000000000..c725be88106 --- /dev/null +++ b/homeassistant/components/faa_delays/const.py @@ -0,0 +1,28 @@ +"""Constants for the FAA Delays integration.""" + +from homeassistant.const import ATTR_ICON, ATTR_NAME + +DOMAIN = "faa_delays" + +FAA_BINARY_SENSORS = { + "GROUND_DELAY": { + ATTR_NAME: "Ground Delay", + ATTR_ICON: "mdi:airport", + }, + "GROUND_STOP": { + ATTR_NAME: "Ground Stop", + ATTR_ICON: "mdi:airport", + }, + "DEPART_DELAY": { + ATTR_NAME: "Departure Delay", + ATTR_ICON: "mdi:airplane-takeoff", + }, + "ARRIVE_DELAY": { + ATTR_NAME: "Arrival Delay", + ATTR_ICON: "mdi:airplane-landing", + }, + "CLOSURE": { + ATTR_NAME: "Closure", + ATTR_ICON: "mdi:airplane:off", + }, +} diff --git a/homeassistant/components/faa_delays/manifest.json b/homeassistant/components/faa_delays/manifest.json new file mode 100644 index 00000000000..4148e7b956f --- /dev/null +++ b/homeassistant/components/faa_delays/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "faa_delays", + "name": "FAA Delays", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/faadelays", + "requirements": ["faadelays==0.0.6"], + "codeowners": ["@ntilley905"] +} diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json new file mode 100644 index 00000000000..92a9dafb4da --- /dev/null +++ b/homeassistant/components/faa_delays/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "FAA Delays", + "description": "Enter a US Airport Code in IATA Format", + "data": { + "id": "Airport" + } + } + }, + "error": { + "invalid_airport": "Airport code is not valid", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "This airport is already configured." + } + } +} diff --git a/homeassistant/components/faa_delays/translations/ca.json b/homeassistant/components/faa_delays/translations/ca.json new file mode 100644 index 00000000000..e7e600f7f07 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Aeroport ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_airport": "Codi d'aeroport inv\u00e0lid", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "id": "Aeroport" + }, + "description": "Introdueix codi d'un aeroport dels EUA en format IATA", + "title": "FAA Delays" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/cs.json b/homeassistant/components/faa_delays/translations/cs.json new file mode 100644 index 00000000000..60e4aed57a2 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/cs.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/de.json b/homeassistant/components/faa_delays/translations/de.json new file mode 100644 index 00000000000..72b837c862c --- /dev/null +++ b/homeassistant/components/faa_delays/translations/de.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/en.json b/homeassistant/components/faa_delays/translations/en.json new file mode 100644 index 00000000000..e78b15c68cb --- /dev/null +++ b/homeassistant/components/faa_delays/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "This airport is already configured." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_airport": "Airport code is not valid", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "id": "Airport" + }, + "description": "Enter a US Airport Code in IATA Format", + "title": "FAA Delays" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/es.json b/homeassistant/components/faa_delays/translations/es.json new file mode 100644 index 00000000000..94eca99dda3 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Este aeropuerto ya est\u00e1 configurado." + }, + "error": { + "invalid_airport": "El c\u00f3digo del aeropuerto no es v\u00e1lido" + }, + "step": { + "user": { + "data": { + "id": "Aeropuerto" + }, + "description": "Introduzca un c\u00f3digo de aeropuerto estadounidense en formato IATA", + "title": "Retrasos de la FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/et.json b/homeassistant/components/faa_delays/translations/et.json new file mode 100644 index 00000000000..75b52558374 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "See lennujaam on juba seadistatud." + }, + "error": { + "cannot_connect": "\u00dchendumine nurjus", + "invalid_airport": "Lennujaama kood ei sobi", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "id": "Lennujaam" + }, + "description": "Sisesta USA lennujaama kood IATA vormingus", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/fr.json b/homeassistant/components/faa_delays/translations/fr.json new file mode 100644 index 00000000000..996a22c8422 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Cet a\u00e9roport est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_airport": "Le code de l'a\u00e9roport n'est pas valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "id": "A\u00e9roport" + }, + "description": "Entrez un code d'a\u00e9roport am\u00e9ricain au format IATA", + "title": "D\u00e9lais FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/he.json b/homeassistant/components/faa_delays/translations/he.json new file mode 100644 index 00000000000..af8d410eb18 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e0\u05de\u05dc \u05ea\u05e2\u05d5\u05e4\u05d4 \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "error": { + "cannot_connect": "\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": { + "id": "\u05e0\u05de\u05dc \u05ea\u05e2\u05d5\u05e4\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/it.json b/homeassistant/components/faa_delays/translations/it.json new file mode 100644 index 00000000000..e1bf6ad0646 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Questo aeroporto \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_airport": "Il codice dell'aeroporto non \u00e8 valido", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "id": "Aeroporto" + }, + "description": "Immettere un codice aeroporto statunitense in formato IATA", + "title": "Ritardi FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/nl.json b/homeassistant/components/faa_delays/translations/nl.json new file mode 100644 index 00000000000..3dbc55f5b1b --- /dev/null +++ b/homeassistant/components/faa_delays/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Deze luchthaven is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_airport": "Luchthavencode is ongeldig", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "id": "Luchthaven" + }, + "description": "Voer een Amerikaanse luchthavencode in IATA-indeling in", + "title": "FAA-vertragingen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/no.json b/homeassistant/components/faa_delays/translations/no.json new file mode 100644 index 00000000000..5a5aac723ad --- /dev/null +++ b/homeassistant/components/faa_delays/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Denne flyplassen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_airport": "Flyplasskoden er ikke gyldig", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "id": "Flyplass" + }, + "description": "Skriv inn en amerikansk flyplasskode i IATA-format", + "title": "FAA forsinkelser" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/ru.json b/homeassistant/components/faa_delays/translations/ru.json new file mode 100644 index 00000000000..d68810fc957 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0430\u044d\u0440\u043e\u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_airport": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 \u0430\u044d\u0440\u043e\u043f\u043e\u0440\u0442\u0430.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "id": "\u0410\u044d\u0440\u043e\u043f\u043e\u0440\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0430\u044d\u0440\u043e\u043f\u043e\u0440\u0442\u0430 \u0421\u0428\u0410 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 IATA.", + "title": "FAA Delays" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/zh-Hant.json b/homeassistant/components/faa_delays/translations/zh-Hant.json new file mode 100644 index 00000000000..f2585bb790f --- /dev/null +++ b/homeassistant/components/faa_delays/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u6a5f\u5834\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_airport": "\u6a5f\u5834\u4ee3\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "id": "\u6a5f\u5834" + }, + "description": "\u8f38\u5165\u7f8e\u570b\u6a5f\u5834 IATA \u4ee3\u78bc", + "title": "FAA \u822a\u73ed\u5ef6\u8aa4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py index ee6e4d8a6fa..6a460ac305b 100644 --- a/homeassistant/components/facebox/image_processing.py +++ b/homeassistant/components/facebox/image_processing.py @@ -15,6 +15,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ID, ATTR_NAME, CONF_IP_ADDRESS, CONF_PASSWORD, @@ -34,7 +35,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_BOUNDING_BOX = "bounding_box" ATTR_CLASSIFIER = "classifier" ATTR_IMAGE_ID = "image_id" -ATTR_ID = "id" ATTR_MATCHED = "matched" FACEBOX_NAME = "name" CLASSIFIER = "facebox" diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 90a3d030703..18f46b3d619 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,7 +2,8 @@ from datetime import timedelta import functools as ft import logging -from typing import Optional +import math +from typing import List, Optional import voluptuous as vol @@ -20,6 +21,12 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) _LOGGER = logging.getLogger(__name__) @@ -32,10 +39,15 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SUPPORT_SET_SPEED = 1 SUPPORT_OSCILLATE = 2 SUPPORT_DIRECTION = 4 +SUPPORT_PRESET_MODE = 8 SERVICE_SET_SPEED = "set_speed" +SERVICE_INCREASE_SPEED = "increase_speed" +SERVICE_DECREASE_SPEED = "decrease_speed" SERVICE_OSCILLATE = "oscillate" SERVICE_SET_DIRECTION = "set_direction" +SERVICE_SET_PERCENTAGE = "set_percentage" +SERVICE_SET_PRESET_MODE = "set_preset_mode" SPEED_OFF = "off" SPEED_LOW = "low" @@ -46,9 +58,54 @@ DIRECTION_FORWARD = "forward" DIRECTION_REVERSE = "reverse" ATTR_SPEED = "speed" +ATTR_PERCENTAGE = "percentage" +ATTR_PERCENTAGE_STEP = "percentage_step" ATTR_SPEED_LIST = "speed_list" ATTR_OSCILLATING = "oscillating" ATTR_DIRECTION = "direction" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" + +# Invalid speeds do not conform to the entity model, but have crept +# into core integrations at some point so we are temporarily +# accommodating them in the transition to percentages. +_NOT_SPEED_OFF = "off" +_NOT_SPEED_ON = "on" +_NOT_SPEED_AUTO = "auto" +_NOT_SPEED_SMART = "smart" +_NOT_SPEED_INTERVAL = "interval" +_NOT_SPEED_IDLE = "idle" +_NOT_SPEED_FAVORITE = "favorite" +_NOT_SPEED_SLEEP = "sleep" + +_NOT_SPEEDS_FILTER = { + _NOT_SPEED_OFF, + _NOT_SPEED_ON, + _NOT_SPEED_AUTO, + _NOT_SPEED_SMART, + _NOT_SPEED_INTERVAL, + _NOT_SPEED_IDLE, + _NOT_SPEED_SLEEP, + _NOT_SPEED_FAVORITE, +} + +_FAN_NATIVE = "_fan_native" + +OFF_SPEED_VALUES = [SPEED_OFF, None] + +LEGACY_SPEED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + +class NoValidSpeedsError(ValueError): + """Exception class when there are no valid speeds.""" + + +class NotValidSpeedError(ValueError): + """Exception class when the speed in not in the speed list.""" + + +class NotValidPresetModeError(ValueError): + """Exception class when the preset_mode in not in the preset_modes list.""" @bind_hass @@ -56,7 +113,7 @@ def is_on(hass, entity_id: str) -> bool: """Return if the fans are on based on the statemachine.""" state = hass.states.get(entity_id) if ATTR_SPEED in state.attributes: - return state.attributes[ATTR_SPEED] not in [SPEED_OFF, None] + return state.attributes[ATTR_SPEED] not in OFF_SPEED_VALUES return state.state == STATE_ON @@ -68,15 +125,47 @@ async def async_setup(hass, config: dict): await component.async_setup(config) + # After the transition to percentage and preset_modes concludes, + # switch this back to async_turn_on and remove async_turn_on_compat component.async_register_entity_service( - SERVICE_TURN_ON, {vol.Optional(ATTR_SPEED): cv.string}, "async_turn_on" + SERVICE_TURN_ON, + { + vol.Optional(ATTR_SPEED): cv.string, + vol.Optional(ATTR_PERCENTAGE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(ATTR_PRESET_MODE): cv.string, + }, + "async_turn_on_compat", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + # After the transition to percentage and preset_modes concludes, + # remove this service component.async_register_entity_service( SERVICE_SET_SPEED, {vol.Required(ATTR_SPEED): cv.string}, - "async_set_speed", + "async_set_speed_deprecated", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_INCREASE_SPEED, + { + vol.Optional(ATTR_PERCENTAGE_STEP): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_increase_speed", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_DECREASE_SPEED, + { + vol.Optional(ATTR_PERCENTAGE_STEP): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_decrease_speed", [SUPPORT_SET_SPEED], ) component.async_register_entity_service( @@ -91,6 +180,22 @@ async def async_setup(hass, config: dict): "async_set_direction", [SUPPORT_DIRECTION], ) + component.async_register_entity_service( + SERVICE_SET_PERCENTAGE, + { + vol.Required(ATTR_PERCENTAGE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_percentage", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {vol.Required(ATTR_PRESET_MODE): cv.string}, + "async_set_preset_mode", + [SUPPORT_SET_SPEED, SUPPORT_PRESET_MODE], + ) return True @@ -105,19 +210,120 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) +def _fan_native(method): + """Native fan method not overridden.""" + setattr(method, _FAN_NATIVE, True) + return method + + class FanEntity(ToggleEntity): """Representation of a fan.""" + @_fan_native def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() + async def async_set_speed_deprecated(self, speed: str): + """Set the speed of the fan.""" + _LOGGER.warning( + "fan.set_speed is deprecated, use fan.set_percentage or fan.set_preset_mode instead." + ) + await self.async_set_speed(speed) + + @_fan_native async def async_set_speed(self, speed: str): """Set the speed of the fan.""" if speed == SPEED_OFF: await self.async_turn_off() + return + + if speed in self.preset_modes: + if not hasattr(self.async_set_preset_mode, _FAN_NATIVE): + await self.async_set_preset_mode(speed) + return + if not hasattr(self.set_preset_mode, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_preset_mode, speed) + return else: - await self.hass.async_add_executor_job(self.set_speed, speed) + if not hasattr(self.async_set_percentage, _FAN_NATIVE): + await self.async_set_percentage(self.speed_to_percentage(speed)) + return + if not hasattr(self.set_percentage, _FAN_NATIVE): + await self.hass.async_add_executor_job( + self.set_percentage, self.speed_to_percentage(speed) + ) + return + + await self.hass.async_add_executor_job(self.set_speed, speed) + + @_fan_native + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + raise NotImplementedError() + + @_fan_native + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage == 0: + await self.async_turn_off() + elif not hasattr(self.set_percentage, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_percentage, percentage) + else: + await self.async_set_speed(self.percentage_to_speed(percentage)) + + async def async_increase_speed(self, percentage_step: Optional[int] = None) -> None: + """Increase the speed of the fan.""" + await self._async_adjust_speed(1, percentage_step) + + async def async_decrease_speed(self, percentage_step: Optional[int] = None) -> None: + """Decrease the speed of the fan.""" + await self._async_adjust_speed(-1, percentage_step) + + async def _async_adjust_speed( + self, modifier: int, percentage_step: Optional[int] + ) -> None: + """Increase or decrease the speed of the fan.""" + current_percentage = self.percentage or 0 + + if percentage_step is not None: + new_percentage = current_percentage + (percentage_step * modifier) + else: + speed_range = (1, self.speed_count) + speed_index = math.ceil( + percentage_to_ranged_value(speed_range, current_percentage) + ) + new_percentage = ranged_value_to_percentage( + speed_range, speed_index + modifier + ) + + new_percentage = max(0, min(100, new_percentage)) + + await self.async_set_percentage(new_percentage) + + @_fan_native + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + self.set_speed(preset_mode) + + @_fan_native + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if not hasattr(self.set_preset_mode, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + return + + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_speed(preset_mode) + + def _valid_preset_mode_or_raise(self, preset_mode): + """Raise NotValidPresetModeError on invalid preset_mode.""" + preset_modes = self.preset_modes + if preset_mode not in preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {preset_modes}" + ) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" @@ -128,18 +334,75 @@ class FanEntity(ToggleEntity): await self.hass.async_add_executor_job(self.set_direction, direction) # pylint: disable=arguments-differ - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" raise NotImplementedError() # pylint: disable=arguments-differ - async def async_turn_on(self, speed: Optional[str] = None, **kwargs): + async def async_turn_on_compat( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: + """Turn on the fan. + + This _compat version wraps async_turn_on with + backwards and forward compatibility. + + After the transition to percentage and preset_modes concludes, it + should be removed. + """ + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + speed = preset_mode + percentage = None + elif speed is not None: + _LOGGER.warning( + "Calling fan.turn_on with the speed argument is deprecated, use percentage or preset_mode instead." + ) + if speed in self.preset_modes: + preset_mode = speed + percentage = None + else: + percentage = self.speed_to_percentage(speed) + elif percentage is not None: + speed = self.percentage_to_speed(percentage) + + await self.async_turn_on( + speed=speed, + percentage=percentage, + preset_mode=preset_mode, + **kwargs, + ) + + # pylint: disable=arguments-differ + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed == SPEED_OFF: await self.async_turn_off() else: await self.hass.async_add_executor_job( - ft.partial(self.turn_on, speed, **kwargs) + ft.partial( + self.turn_on, + speed=speed, + percentage=percentage, + preset_mode=preset_mode, + **kwargs, + ) ) def oscillate(self, oscillating: bool) -> None: @@ -155,15 +418,73 @@ class FanEntity(ToggleEntity): """Return true if the entity is on.""" return self.speed not in [SPEED_OFF, None] + @property + def _implemented_percentage(self): + """Return true if percentage has been implemented.""" + return not hasattr(self.set_percentage, _FAN_NATIVE) or not hasattr( + self.async_set_percentage, _FAN_NATIVE + ) + + @property + def _implemented_preset_mode(self): + """Return true if preset_mode has been implemented.""" + return not hasattr(self.set_preset_mode, _FAN_NATIVE) or not hasattr( + self.async_set_preset_mode, _FAN_NATIVE + ) + + @property + def _implemented_speed(self): + """Return true if speed has been implemented.""" + return not hasattr(self.set_speed, _FAN_NATIVE) or not hasattr( + self.async_set_speed, _FAN_NATIVE + ) + @property def speed(self) -> Optional[str]: """Return the current speed.""" + if self._implemented_preset_mode: + preset_mode = self.preset_mode + if preset_mode: + return preset_mode + if self._implemented_percentage: + percentage = self.percentage + if percentage is None: + return None + return self.percentage_to_speed(percentage) return None + @property + def percentage(self) -> Optional[int]: + """Return the current speed as a percentage.""" + if not self._implemented_preset_mode: + if self.speed in self.preset_modes: + return None + if not self._implemented_percentage: + return self.speed_to_percentage(self.speed) + return 0 + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + speed_list = speed_list_without_preset_modes(self.speed_list) + if speed_list: + return len(speed_list) + return 100 + + @property + def percentage_step(self) -> float: + """Return the step size for percentage.""" + return 100 / self.speed_count + @property def speed_list(self) -> list: """Get the list of available speeds.""" - return [] + speeds = [] + if self._implemented_percentage: + speeds += [SPEED_OFF, *LEGACY_SPEED_LIST] + if self._implemented_preset_mode: + speeds += self.preset_modes + return speeds @property def current_direction(self) -> Optional[str]: @@ -178,9 +499,90 @@ class FanEntity(ToggleEntity): @property def capability_attributes(self): """Return capability attributes.""" + attrs = {} if self.supported_features & SUPPORT_SET_SPEED: - return {ATTR_SPEED_LIST: self.speed_list} - return {} + attrs[ATTR_SPEED_LIST] = self.speed_list + + if ( + self.supported_features & SUPPORT_SET_SPEED + or self.supported_features & SUPPORT_PRESET_MODE + ): + attrs[ATTR_PRESET_MODES] = self.preset_modes + + return attrs + + @property + def _speed_list_without_preset_modes(self) -> list: + """Return the speed list without preset modes. + + This property provides forward and backwards + compatibility for conversion to percentage speeds. + """ + if not self._implemented_speed: + return LEGACY_SPEED_LIST + return speed_list_without_preset_modes(self.speed_list) + + def speed_to_percentage(self, speed: str) -> int: + """ + Map a speed to a percentage. + + Officially this should only have to deal with the 4 pre-defined speeds: + + return { + SPEED_OFF: 0, + SPEED_LOW: 33, + SPEED_MEDIUM: 66, + SPEED_HIGH: 100, + }[speed] + + Unfortunately lots of fans make up their own speeds. So the default + mapping is more dynamic. + """ + if speed in OFF_SPEED_VALUES: + return 0 + + speed_list = self._speed_list_without_preset_modes + + if speed_list and speed not in speed_list: + raise NotValidSpeedError(f"The speed {speed} is not a valid speed.") + + try: + return ordered_list_item_to_percentage(speed_list, speed) + except ValueError as ex: + raise NoValidSpeedsError( + f"The speed_list {speed_list} does not contain any valid speeds." + ) from ex + + def percentage_to_speed(self, percentage: int) -> str: + """ + Map a percentage onto self.speed_list. + + Officially, this should only have to deal with 4 pre-defined speeds. + + if value == 0: + return SPEED_OFF + elif value <= 33: + return SPEED_LOW + elif value <= 66: + return SPEED_MEDIUM + else: + return SPEED_HIGH + + Unfortunately there is currently a high degree of non-conformancy. + Until fans have been corrected a more complicated and dynamic + mapping is used. + """ + if percentage == 0: + return SPEED_OFF + + speed_list = self._speed_list_without_preset_modes + + try: + return percentage_to_ordered_list_item(speed_list, percentage) + except ValueError as ex: + raise NoValidSpeedsError( + f"The speed_list {speed_list} does not contain any valid speeds." + ) from ex @property def state_attributes(self) -> dict: @@ -196,6 +598,14 @@ class FanEntity(ToggleEntity): if supported_features & SUPPORT_SET_SPEED: data[ATTR_SPEED] = self.speed + data[ATTR_PERCENTAGE] = self.percentage + data[ATTR_PERCENTAGE_STEP] = self.percentage_step + + if ( + supported_features & SUPPORT_PRESET_MODE + or supported_features & SUPPORT_SET_SPEED + ): + data[ATTR_PRESET_MODE] = self.preset_mode return data @@ -203,3 +613,72 @@ class FanEntity(ToggleEntity): def supported_features(self) -> int: """Flag supported features.""" return 0 + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite. + + Requires SUPPORT_SET_SPEED. + """ + speed = self.speed + if speed in self.preset_modes: + return speed + return None + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes. + + Requires SUPPORT_SET_SPEED. + """ + return preset_modes_from_speed_list(self.speed_list) + + +def speed_list_without_preset_modes(speed_list: List): + """Filter out non-speeds from the speed list. + + The goal is to get the speeds in a list from lowest to + highest by removing speeds that are not valid or out of order + so we can map them to percentages. + + Examples: + input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] + output: ["low", "low-medium", "medium", "medium-high", "high"] + + input: ["off", "auto", "low", "medium", "high"] + output: ["low", "medium", "high"] + + input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] + output: ["1", "2", "3", "4", "5", "6", "7"] + + input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] + output: ["Silent", "Medium", "High", "Strong"] + """ + + return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER] + + +def preset_modes_from_speed_list(speed_list: List): + """Filter out non-preset modes from the speed list. + + The goal is to return only preset modes. + + Examples: + input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] + output: ["auto"] + + input: ["off", "auto", "low", "medium", "high"] + output: ["auto"] + + input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] + output: ["smart"] + + input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] + output: ["Auto", "Favorite", "Idle"] + """ + + return [ + speed + for speed in speed_list + if speed.lower() in _NOT_SPEEDS_FILTER and speed.lower() != SPEED_OFF + ] diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index c78ebcfffe4..95f4b429a24 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -74,16 +74,13 @@ async def async_attach_trigger( config = TRIGGER_SCHEMA(config) if config[CONF_TYPE] == "turned_on": - from_state = STATE_OFF to_state = STATE_ON else: - from_state = STATE_ON to_state = STATE_OFF state_config = { state_trigger.CONF_PLATFORM: "state", CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state_trigger.CONF_FROM: from_state, state_trigger.CONF_TO: to_state, } state_config = state_trigger.TRIGGER_SCHEMA(state_config) diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index 55ae78d90f6..b5f0fca47b7 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -17,10 +17,14 @@ from homeassistant.helpers.typing import HomeAssistantType from . import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, ) @@ -31,6 +35,8 @@ ATTRIBUTES = { # attribute: service ATTR_DIRECTION: SERVICE_SET_DIRECTION, ATTR_OSCILLATING: SERVICE_OSCILLATE, ATTR_SPEED: SERVICE_SET_SPEED, + ATTR_PERCENTAGE: SERVICE_SET_PERCENTAGE, + ATTR_PRESET_MODE: SERVICE_SET_PRESET_MODE, } diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 1fb88a36d2c..f86a32823dc 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,54 +1,146 @@ # Describes the format for available fan services set_speed: - description: Sets fan speed. + name: Set speed + description: Set fan speed. + target: fields: - entity_id: - description: Name(s) of the entities to set - example: "fan.living_room" speed: - description: Speed setting + name: Speed + description: Speed setting. + required: true example: "low" + selector: + text: + +set_preset_mode: + name: Set preset mode + description: Set preset mode for a fan device. + target: + fields: + preset_mode: + name: Preset mode + description: New value of preset mode. + required: true + example: "auto" + selector: + text: + +set_percentage: + name: Set speed percentage + description: Set fan speed percentage. + target: + fields: + percentage: + name: Percentage + description: Percentage speed setting. + required: true + example: 25 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider turn_on: - description: Turns fan on. + name: Turn on + description: Turn fan on. + target: fields: - entity_id: - description: Names(s) of the entities to turn on - example: "fan.living_room" speed: - description: Speed setting + name: Speed + description: Speed setting. example: "high" + percentage: + name: Percentage + description: Percentage speed setting. + example: 75 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + preset_mode: + name: Preset mode + description: Preset mode setting. + example: "auto" + selector: + text: turn_off: - description: Turns fan off. - fields: - entity_id: - description: Names(s) of the entities to turn off - example: "fan.living_room" + name: Turn off + description: Turn fan off. + target: oscillate: - description: Oscillates the fan. + name: Oscillate + description: Oscillate the fan. + target: fields: - entity_id: - description: Name(s) of the entities to oscillate - example: "fan.desk_fan" oscillating: - description: Flag to turn on/off oscillation + name: Oscillating + description: Flag to turn on/off oscillation. + required: true example: true + selector: + boolean: toggle: + name: Toggle description: Toggle the fan on/off. - fields: - entity_id: - description: Name(s) of the entities to toggle - example: "fan.living_room" + target: set_direction: + name: Set direction description: Set the fan rotation. + target: fields: - entity_id: - description: Name(s) of the entities to set - example: "fan.living_room" direction: - description: The direction to rotate. Either 'forward' or 'reverse' + name: Direction + description: The direction to rotate. + required: true example: "forward" + selector: + select: + options: + - "forward" + - "reverse" + +increase_speed: + name: Increase speed + description: Increase the speed of the fan by one speed or a percentage_step. + target: + fields: + percentage_step: + advanced: true + required: false + description: Increase speed by a percentage. + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + +decrease_speed: + name: Decrease speed + description: Decrease the speed of the fan by one speed or a percentage_step. + target: + fields: + percentage_step: + advanced: true + required: false + description: Decrease speed by a percentage. + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 314fbbd2210..ecbf6f3b1ae 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -14,13 +14,12 @@ from homeassistant.components.ffmpeg import ( DATA_FFMPEG, FFmpegBase, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_REPEAT from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_RESET = "reset" CONF_CHANGES = "changes" -CONF_REPEAT = "repeat" CONF_REPEAT_TIME = "repeat_time" DEFAULT_NAME = "FFmpeg Motion" @@ -88,7 +87,6 @@ class FFmpegMotion(FFmpegBinarySensor): def __init__(self, hass, manager, config): """Initialize FFmpeg motion binary sensor.""" - super().__init__(config) self.ffmpeg = ffmpeg_sensor.SensorMotion(manager.binary, self._async_callback) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 6dc69df1343..aed1da543ee 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -118,13 +118,11 @@ class FibaroLight(FibaroDevice, LightEntity): # We set it to the target brightness and turn it on self._brightness = scaleto100(target_brightness) - if self._supported_flags & SUPPORT_COLOR: - if ( - self._reset_color - and kwargs.get(ATTR_WHITE_VALUE) is None - and kwargs.get(ATTR_HS_COLOR) is None - and kwargs.get(ATTR_BRIGHTNESS) is None - ): + if self._supported_flags & SUPPORT_COLOR and ( + kwargs.get(ATTR_WHITE_VALUE) is not None + or kwargs.get(ATTR_HS_COLOR) is not None + ): + if self._reset_color: self._color = (100, 0) # Update based on parameters @@ -132,14 +130,14 @@ class FibaroLight(FibaroDevice, LightEntity): self._color = kwargs.get(ATTR_HS_COLOR, self._color) rgb = color_util.color_hs_to_RGB(*self._color) self.call_set_color( - round(rgb[0] * self._brightness / 100.0), - round(rgb[1] * self._brightness / 100.0), - round(rgb[2] * self._brightness / 100.0), - round(self._white * self._brightness / 100.0), + round(rgb[0]), + round(rgb[1]), + round(rgb[2]), + round(self._white), ) if self.state == "off": - self.set_level(int(self._brightness)) + self.set_level(min(int(self._brightness), 99)) return if self._reset_color: @@ -147,7 +145,7 @@ class FibaroLight(FibaroDevice, LightEntity): self.call_set_color(bri255, bri255, bri255, bri255) if self._supported_flags & SUPPORT_BRIGHTNESS: - self.set_level(int(self._brightness)) + self.set_level(min(int(self._brightness), 99)) return # The simplest case is left for last. No dimming, just switch on @@ -203,4 +201,4 @@ class FibaroLight(FibaroDevice, LightEntity): if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) if (self._supported_flags & SUPPORT_WHITE_VALUE) and self.brightness != 0: - self._white = min(255, max(0, rgbw_list[3] * 100.0 / self._brightness)) + self._white = min(255, max(0, rgbw_list[3])) diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index e928541a724..3368bd878d5 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -5,14 +5,17 @@ import os import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE +from homeassistant.const import ( + CONF_FILE_PATH, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_FILE_PATH = "file_path" - DEFAULT_NAME = "File" ICON = "mdi:file" diff --git a/homeassistant/components/filter/services.yaml b/homeassistant/components/filter/services.yaml index 6f6ea1b04d6..7d64b34a4f7 100644 --- a/homeassistant/components/filter/services.yaml +++ b/homeassistant/components/filter/services.yaml @@ -1,2 +1,2 @@ reload: - description: Reload all filter entities. + description: Reload all filter entities diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index fc06e605cbd..3f04adc2f72 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -1,6 +1,4 @@ """Binary Sensor platform for FireServiceRota integration.""" -import logging - from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType @@ -11,8 +9,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json index c44673d6c2c..aef6f1b6849 100644 --- a/homeassistant/components/fireservicerota/strings.json +++ b/homeassistant/components/fireservicerota/strings.json @@ -9,7 +9,7 @@ } }, "reauth": { - "description": "Authentication tokens baceame invalid, login to recreate them.", + "description": "Authentication tokens became invalid, login to recreate them.", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/fireservicerota/translations/en.json b/homeassistant/components/fireservicerota/translations/en.json index 288b89c31b8..a059081760d 100644 --- a/homeassistant/components/fireservicerota/translations/en.json +++ b/homeassistant/components/fireservicerota/translations/en.json @@ -15,7 +15,7 @@ "data": { "password": "Password" }, - "description": "Authentication tokens baceame invalid, login to recreate them." + "description": "Authentication tokens became invalid, login to recreate them." }, "user": { "data": { diff --git a/homeassistant/components/fireservicerota/translations/fr.json b/homeassistant/components/fireservicerota/translations/fr.json index a8803f63fca..fdbf28e32e1 100644 --- a/homeassistant/components/fireservicerota/translations/fr.json +++ b/homeassistant/components/fireservicerota/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Le compte \u00e0 d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9" + "already_configured": "Le compte \u00e0 d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { "default": "Autentification r\u00e9ussie" @@ -13,7 +14,8 @@ "reauth": { "data": { "password": "Mot de passe" - } + }, + "description": "Les jetons d'authentification sont invalides, connectez-vous pour les recr\u00e9er." }, "user": { "data": { diff --git a/homeassistant/components/fireservicerota/translations/it.json b/homeassistant/components/fireservicerota/translations/it.json index 8fc43f294ec..6960b68b2a2 100644 --- a/homeassistant/components/fireservicerota/translations/it.json +++ b/homeassistant/components/fireservicerota/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "create_entry": { "default": "Autenticazione riuscita" diff --git a/homeassistant/components/fireservicerota/translations/ko.json b/homeassistant/components/fireservicerota/translations/ko.json new file mode 100644 index 00000000000..f705fd9873c --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "create_entry": { + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "reauth": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + } + }, + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/nl.json b/homeassistant/components/fireservicerota/translations/nl.json new file mode 100644 index 00000000000..3a6ba936dee --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "create_entry": { + "default": "Succesvol geauthenticeerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "reauth": { + "data": { + "password": "Wachtwoord" + }, + "description": "Authenticatietokens zijn ongeldig geworden, log in om ze opnieuw te maken." + }, + "user": { + "data": { + "password": "Wachtwoord", + "url": "Website", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/no.json b/homeassistant/components/fireservicerota/translations/no.json index af1ceba2c97..be485577e65 100644 --- a/homeassistant/components/fireservicerota/translations/no.json +++ b/homeassistant/components/fireservicerota/translations/no.json @@ -15,7 +15,7 @@ "data": { "password": "Passord" }, - "description": "Godkjenningstokener ble ugyldige, logg inn for \u00e5 gjenopprette dem" + "description": "Autentiseringstokener ble ugyldige, logg inn for \u00e5 gjenskape dem." }, "user": { "data": { diff --git a/homeassistant/components/fireservicerota/translations/ru.json b/homeassistant/components/fireservicerota/translations/ru.json index 2c90bd53ca9..3955172e02d 100644 --- a/homeassistant/components/fireservicerota/translations/ru.json +++ b/homeassistant/components/fireservicerota/translations/ru.json @@ -8,7 +8,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/fireservicerota/translations/zh-Hant.json b/homeassistant/components/fireservicerota/translations/zh-Hant.json index af3cba40dc6..8e5f4d9f20d 100644 --- a/homeassistant/components/fireservicerota/translations/zh-Hant.json +++ b/homeassistant/components/fireservicerota/translations/zh-Hant.json @@ -15,7 +15,7 @@ "data": { "password": "\u5bc6\u78bc" }, - "description": "\u8a8d\u8b49\u5bc6\u9470\u5df2\u7d93\u5931\u6548\uff0c\u8acb\u767b\u5165\u91cd\u65b0\u65b0\u589e\u3002" + "description": "\u8a8d\u8b49\u6b0a\u6756\u5df2\u7d93\u5931\u6548\uff0c\u8acb\u767b\u5165\u91cd\u65b0\u65b0\u589e\u3002" }, "user": { "data": { diff --git a/homeassistant/components/firmata/const.py b/homeassistant/components/firmata/const.py index 6259582b5f7..0d859363e2b 100644 --- a/homeassistant/components/firmata/const.py +++ b/homeassistant/components/firmata/const.py @@ -10,7 +10,6 @@ CONF_ARDUINO_INSTANCE_ID = "arduino_instance_id" CONF_ARDUINO_WAIT = "arduino_wait" CONF_DIFFERENTIAL = "differential" CONF_INITIAL_STATE = "initial" -CONF_NAME = "name" CONF_NEGATE_STATE = "negate" CONF_PINS = "pins" CONF_PIN_MODE = "pin_mode" diff --git a/homeassistant/components/firmata/translations/ko.json b/homeassistant/components/firmata/translations/ko.json index 753a5851811..b5b8d46f329 100644 --- a/homeassistant/components/firmata/translations/ko.json +++ b/homeassistant/components/firmata/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "\uc124\uce58\ud558\ub294 \ub3d9\uc548 Firmata \ubcf4\ub4dc\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" } } } \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/nl.json b/homeassistant/components/firmata/translations/nl.json new file mode 100644 index 00000000000..7cb0141826a --- /dev/null +++ b/homeassistant/components/firmata/translations/nl.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "Kan geen verbinding maken" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index e3dfd432a41..99ebbdd6bb6 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -7,7 +7,7 @@ from fixerio.exceptions import FixerioException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME, CONF_TARGET import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -17,8 +17,6 @@ ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" ATTRIBUTION = "Data provided by the European Central Bank (ECB)" -CONF_TARGET = "target" - DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" @@ -37,7 +35,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Fixer.io sensor.""" - api_key = config.get(CONF_API_KEY) name = config.get(CONF_NAME) target = config.get(CONF_TARGET) @@ -103,7 +100,6 @@ class ExchangeData: def __init__(self, target_currency, api_key): """Initialize the data object.""" - self.api_key = api_key self.rate = None self.target_currency = target_currency diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index d46bbc9c85b..1f4c0d0ddfc 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -9,6 +9,7 @@ from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_INCLUDE, CONF_PASSWORD, CONF_USERNAME, ) @@ -17,8 +18,6 @@ from homeassistant.helpers.event import track_utc_time_change _LOGGER = logging.getLogger(__name__) -CONF_INCLUDE = "include" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -44,7 +43,6 @@ class FleetGoDeviceScanner: def __init__(self, config, see): """Initialize FleetGoDeviceScanner.""" - self._include = config.get(CONF_INCLUDE) self._see = see diff --git a/homeassistant/components/flick_electric/translations/ko.json b/homeassistant/components/flick_electric/translations/ko.json index 82d095f2755..e5b69253fa7 100644 --- a/homeassistant/components/flick_electric/translations/ko.json +++ b/homeassistant/components/flick_electric/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\ud574\ub2f9 \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/flick_electric/translations/ru.json b/homeassistant/components/flick_electric/translations/ru.json index c97bb9133cc..bcabe2f2157 100644 --- a/homeassistant/components/flick_electric/translations/ru.json +++ b/homeassistant/components/flick_electric/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/flo/translations/ko.json b/homeassistant/components/flo/translations/ko.json index ab85b70afa7..9ba063c37dd 100644 --- a/homeassistant/components/flo/translations/ko.json +++ b/homeassistant/components/flo/translations/ko.json @@ -1,17 +1,19 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { "data": { - "host": "\ud638\uc2a4\ud2b8" + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" } } } diff --git a/homeassistant/components/flo/translations/ru.json b/homeassistant/components/flo/translations/ru.json index 6f71ee41376..9e0db9fcf94 100644 --- a/homeassistant/components/flo/translations/ru.json +++ b/homeassistant/components/flo/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/flume/translations/ko.json b/homeassistant/components/flume/translations/ko.json index faac5e9c579..b700854ab57 100644 --- a/homeassistant/components/flume/translations/ko.json +++ b/homeassistant/components/flume/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/flume/translations/ru.json b/homeassistant/components/flume/translations/ru.json index f35579c2dee..e4be913abcd 100644 --- a/homeassistant/components/flume/translations/ru.json +++ b/homeassistant/components/flume/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/flunearyou/translations/ko.json b/homeassistant/components/flunearyou/translations/ko.json index 68e65d3c349..f6528e85cca 100644 --- a/homeassistant/components/flunearyou/translations/ko.json +++ b/homeassistant/components/flunearyou/translations/ko.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/flunearyou/translations/nl.json b/homeassistant/components/flunearyou/translations/nl.json index c63a59e18e7..0ff044abc5e 100644 --- a/homeassistant/components/flunearyou/translations/nl.json +++ b/homeassistant/components/flunearyou/translations/nl.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Deze co\u00f6rdinaten zijn al geregistreerd." }, + "error": { + "unknown": "Onverwachte fout" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 4d45f217a59..ab0d296928f 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -22,6 +22,7 @@ from homeassistant.components.light import ( from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_BRIGHTNESS, CONF_LIGHTS, CONF_MODE, CONF_NAME, @@ -49,7 +50,6 @@ CONF_STOP_TIME = "stop_time" CONF_START_CT = "start_colortemp" CONF_SUNSET_CT = "sunset_colortemp" CONF_STOP_CT = "stop_colortemp" -CONF_BRIGHTNESS = "brightness" CONF_DISABLE_BRIGHTNESS_ADJUST = "disable_brightness_adjust" CONF_INTERVAL = "interval" diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 722b60a952d..60239aeb0d1 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==0.8.3"], + "requirements": ["watchdog==1.0.2"], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 285f1382644..adc6e9b7b35 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -188,6 +188,5 @@ class ForkedDaapdFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_NAME: discovery_info["properties"]["Machine Name"], } self.discovery_schema = vol.Schema(fill_in_schema_dict(zeroconf_data)) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": zeroconf_data}) return await self.async_step_user() diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 195ebf7e2cf..724db80fabd 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -855,7 +855,7 @@ class ForkedDaapdUpdater: ) if update_events: await asyncio.wait( - [event.wait() for event in update_events.values()] + [asyncio.create_task(event.wait()) for event in update_events.values()] ) # make sure callbacks done before update async_dispatcher_send( self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True diff --git a/homeassistant/components/forked_daapd/translations/ko.json b/homeassistant/components/forked_daapd/translations/ko.json index 5522eda3a76..5ae487a4096 100644 --- a/homeassistant/components/forked_daapd/translations/ko.json +++ b/homeassistant/components/forked_daapd/translations/ko.json @@ -5,7 +5,7 @@ "not_forked_daapd": "\uae30\uae30\uac00 forked-daapd \uc11c\ubc84\uac00 \uc544\ub2d9\ub2c8\ub2e4." }, "error": { - "unknown_error": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958", + "unknown_error": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", "websocket_not_enabled": "forked-daapd \uc11c\ubc84 \uc6f9\uc18c\ucf13\uc774 \ube44\ud65c\uc131\ud654 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.", "wrong_host_or_port": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694.", "wrong_password": "\ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", diff --git a/homeassistant/components/foscam/translations/ca.json b/homeassistant/components/foscam/translations/ca.json index 5a6c84f400e..b7f71c8c922 100644 --- a/homeassistant/components/foscam/translations/ca.json +++ b/homeassistant/components/foscam/translations/ca.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_response": "Resposta del dispositiu inv\u00e0lida", "unknown": "Error inesperat" }, "step": { @@ -14,6 +15,7 @@ "host": "Amfitri\u00f3", "password": "Contrasenya", "port": "Port", + "rtsp_port": "Port RTSP", "stream": "Flux de v\u00eddeo", "username": "Nom d'usuari" } diff --git a/homeassistant/components/foscam/translations/es.json b/homeassistant/components/foscam/translations/es.json index 27f7ac36489..7e8b7c1427d 100644 --- a/homeassistant/components/foscam/translations/es.json +++ b/homeassistant/components/foscam/translations/es.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_response": "Respuesta no v\u00e1lida del dispositivo", "unknown": "Error inesperado" }, "step": { @@ -14,6 +15,7 @@ "host": "Host", "password": "Contrase\u00f1a", "port": "Puerto", + "rtsp_port": "Puerto RTSP", "stream": "Stream", "username": "Usuario" } diff --git a/homeassistant/components/foscam/translations/et.json b/homeassistant/components/foscam/translations/et.json index b20a33aec1d..c21ffa0cdd1 100644 --- a/homeassistant/components/foscam/translations/et.json +++ b/homeassistant/components/foscam/translations/et.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus", "invalid_auth": "Vigane autentimine", + "invalid_response": "Seadme vastus on vigane", "unknown": "Ootamatu t\u00f5rge" }, "step": { @@ -14,6 +15,7 @@ "host": "Host", "password": "Salas\u00f5na", "port": "Port", + "rtsp_port": "RTSP port", "stream": "Voog", "username": "Kasutajanimi" } diff --git a/homeassistant/components/foscam/translations/fr.json b/homeassistant/components/foscam/translations/fr.json index 9af8115c305..1424c22ad61 100644 --- a/homeassistant/components/foscam/translations/fr.json +++ b/homeassistant/components/foscam/translations/fr.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Echec de connection", "invalid_auth": "Authentification invalide", + "invalid_response": "R\u00e9ponse invalide de l\u2019appareil", "unknown": "Erreur inattendue" }, "step": { @@ -14,6 +15,7 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", + "rtsp_port": "Port RTSP", "stream": "Flux", "username": "Nom d'utilisateur" } diff --git a/homeassistant/components/foscam/translations/it.json b/homeassistant/components/foscam/translations/it.json index 0562012b1fa..63868a0f07f 100644 --- a/homeassistant/components/foscam/translations/it.json +++ b/homeassistant/components/foscam/translations/it.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", + "invalid_response": "Risposta non valida dal dispositivo", "unknown": "Errore imprevisto" }, "step": { @@ -14,6 +15,7 @@ "host": "Host", "password": "Password", "port": "Porta", + "rtsp_port": "Porta RTSP", "stream": "Flusso", "username": "Nome utente" } diff --git a/homeassistant/components/foscam/translations/ko.json b/homeassistant/components/foscam/translations/ko.json new file mode 100644 index 00000000000..bfd8e952671 --- /dev/null +++ b/homeassistant/components/foscam/translations/ko.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/nl.json b/homeassistant/components/foscam/translations/nl.json new file mode 100644 index 00000000000..9bea23ad702 --- /dev/null +++ b/homeassistant/components/foscam/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "invalid_response": "Ongeldig antwoord van het apparaat", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "rtsp_port": "RTSP-poort", + "stream": "Stream", + "username": "Gebruikersnaam" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/no.json b/homeassistant/components/foscam/translations/no.json index 5e1b494c88a..0184213de27 100644 --- a/homeassistant/components/foscam/translations/no.json +++ b/homeassistant/components/foscam/translations/no.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", + "invalid_response": "Ugyldig respons fra enheten", "unknown": "Uventet feil" }, "step": { @@ -14,6 +15,7 @@ "host": "Vert", "password": "Passord", "port": "Port", + "rtsp_port": "RTSP-port", "stream": "Str\u00f8m", "username": "Brukernavn" } diff --git a/homeassistant/components/foscam/translations/pl.json b/homeassistant/components/foscam/translations/pl.json index ef0bcda2b3a..d7494e22063 100644 --- a/homeassistant/components/foscam/translations/pl.json +++ b/homeassistant/components/foscam/translations/pl.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_auth": "Niepoprawne uwierzytelnienie", + "invalid_response": "Nieprawid\u0142owa odpowied\u017a z urz\u0105dzenia", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { @@ -14,6 +15,7 @@ "host": "Nazwa hosta lub adres IP", "password": "Has\u0142o", "port": "Port", + "rtsp_port": "Port RTSP", "stream": "Strumie\u0144", "username": "Nazwa u\u017cytkownika" } diff --git a/homeassistant/components/foscam/translations/ru.json b/homeassistant/components/foscam/translations/ru.json index ad8b7961ca3..f78f64af69a 100644 --- a/homeassistant/components/foscam/translations/ru.json +++ b/homeassistant/components/foscam/translations/ru.json @@ -5,7 +5,8 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_response": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442 \u043e\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { @@ -14,6 +15,7 @@ "host": "\u0425\u043e\u0441\u0442", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", + "rtsp_port": "\u041f\u043e\u0440\u0442 RTSP", "stream": "\u041f\u043e\u0442\u043e\u043a", "username": "\u041b\u043e\u0433\u0438\u043d" } diff --git a/homeassistant/components/foscam/translations/zh-Hant.json b/homeassistant/components/foscam/translations/zh-Hant.json index 2cc6303c17a..a0920c93548 100644 --- a/homeassistant/components/foscam/translations/zh-Hant.json +++ b/homeassistant/components/foscam/translations/zh-Hant.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_response": "\u4f86\u81ea\u88dd\u7f6e\u56de\u61c9\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { @@ -14,6 +15,7 @@ "host": "\u4e3b\u6a5f\u7aef", "password": "\u5bc6\u78bc", "port": "\u901a\u8a0a\u57e0", + "rtsp_port": "RTSP \u57e0", "stream": "\u4e32\u6d41", "username": "\u4f7f\u7528\u8005\u540d\u7a31" } diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index d776c34c4f9..2ee52884c88 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure the Freebox integration.""" import logging -from aiofreepybox.exceptions import AuthorizationError, HttpRequestError +from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index ae96f7f6510..2739849b547 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -3,7 +3,7 @@ "name": "Freebox", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/freebox", - "requirements": ["aiofreepybox==0.0.8"], + "requirements": ["freebox-api==0.0.9"], "after_dependencies": ["discovery"], - "codeowners": ["@snoof85", "@Quentame"] + "codeowners": ["@hacf-fr", "@Quentame"] } diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index daa57a89c47..2511280f719 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,9 +4,9 @@ import logging from pathlib import Path from typing import Any, Dict, List, Optional -from aiofreepybox import Freepybox -from aiofreepybox.api.wifi import Wifi -from aiofreepybox.exceptions import HttpRequestError +from freebox_api import Freepybox +from freebox_api.api.wifi import Wifi +from freebox_api.exceptions import HttpRequestError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -138,7 +138,7 @@ class FreeboxRouter: "serial": syst_datas["serial"], } - self.call_list = await self._api.call.get_call_list() + self.call_list = await self._api.call.get_calls_log() async_dispatcher_send(self.hass, self.signal_sensor_update) diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 00f87e21f47..b1cfc93eb53 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -2,7 +2,7 @@ import logging from typing import Dict -from aiofreepybox.exceptions import InsufficientPermissionsError +from freebox_api.exceptions import InsufficientPermissionsError from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/freebox/translations/ko.json b/homeassistant/components/freebox/translations/ko.json index 986f345b3ec..a8b9a1edc7a 100644 --- a/homeassistant/components/freebox/translations/ko.json +++ b/homeassistant/components/freebox/translations/ko.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "link": { diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 7297f514f96..3c01657da4e 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,9 +2,10 @@ import asyncio import socket -from pyfritzhome import Fritzhome +from pyfritzhome import Fritzhome, LoginError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -62,7 +63,7 @@ async def async_setup(hass, config): for entry_config in config[DOMAIN][CONF_DEVICES]: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": "import"}, data=entry_config + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config ) ) @@ -76,7 +77,18 @@ async def async_setup_entry(hass, entry): user=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) - await hass.async_add_executor_job(fritz.login) + + try: + await hass.async_add_executor_job(fritz.login) + except LoginError: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry, + ) + ) + return False hass.data.setdefault(DOMAIN, {CONF_CONNECTIONS: {}, CONF_DEVICES: set()}) hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] = fritz diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index ee1b3aff241..904081ef99f 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -43,10 +43,9 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - def __init__(self): """Initialize flow.""" + self._entry = None self._host = None self._name = None self._password = None @@ -62,6 +61,17 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + async def _update_entry(self): + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_HOST: self._host, + CONF_PASSWORD: self._password, + CONF_USERNAME: self._username, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + def _try_connect(self): """Try to connect and check auth.""" fritzbox = Fritzhome( @@ -160,3 +170,41 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"name": self._name}, errors=errors, ) + + async def async_step_reauth(self, entry): + """Trigger a reauthentication flow.""" + self._entry = entry + self._host = entry.data[CONF_HOST] + self._name = entry.data[CONF_HOST] + self._username = entry.data[CONF_USERNAME] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + await self._update_entry() + return self.async_abort(reason="reauth_successful") + if result != RESULT_INVALID_AUTH: + return self.async_abort(reason=result) + errors["base"] = result + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._username): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={"name": self._name}, + errors=errors, + ) diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 141348583f4..6de6b6d9d9a 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -16,16 +16,24 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Update your login information for {name}.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices." + "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/de.json b/homeassistant/components/fritzbox/translations/de.json index 9b76ad19ff4..16263722482 100644 --- a/homeassistant/components/fritzbox/translations/de.json +++ b/homeassistant/components/fritzbox/translations/de.json @@ -4,7 +4,8 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", - "not_supported": "Verbunden mit AVM FRITZ! Box, kann jedoch keine Smart Home-Ger\u00e4te steuern." + "not_supported": "Verbunden mit AVM FRITZ! Box, kann jedoch keine Smart Home-Ger\u00e4te steuern.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Zugangsdaten" @@ -18,6 +19,13 @@ }, "description": "M\u00f6chtest du {name} einrichten?" }, + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Aktualisiere deine Anmeldeinformationen f\u00fcr {name}." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/fritzbox/translations/es.json b/homeassistant/components/fritzbox/translations/es.json index 5a9544df4e5..fcb240deb77 100644 --- a/homeassistant/components/fritzbox/translations/es.json +++ b/homeassistant/components/fritzbox/translations/es.json @@ -4,7 +4,8 @@ "already_configured": "Este AVM FRITZ!Box ya est\u00e1 configurado.", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", "no_devices_found": "No se encontraron dispositivos en la red", - "not_supported": "Conectado a AVM FRITZ!Box pero no es capaz de controlar dispositivos Smart Home." + "not_supported": "Conectado a AVM FRITZ!Box pero no es capaz de controlar dispositivos Smart Home.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" @@ -18,6 +19,13 @@ }, "description": "\u00bfQuieres configurar {name}?" }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Actualice la informaci\u00f3n de inicio de sesi\u00f3n para {name}." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/fritzbox/translations/fr.json b/homeassistant/components/fritzbox/translations/fr.json index e7a8acaa762..e6302964988 100644 --- a/homeassistant/components/fritzbox/translations/fr.json +++ b/homeassistant/components/fritzbox/translations/fr.json @@ -4,7 +4,8 @@ "already_configured": "Cette AVM FRITZ!Box est d\u00e9j\u00e0 configur\u00e9e.", "already_in_progress": "Une configuration d'AVM FRITZ!Box est d\u00e9j\u00e0 en cours.", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", - "not_supported": "Connect\u00e9 \u00e0 AVM FRITZ! Box mais impossible de contr\u00f4ler les appareils Smart Home." + "not_supported": "Connect\u00e9 \u00e0 AVM FRITZ! Box mais impossible de contr\u00f4ler les appareils Smart Home.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide" @@ -18,6 +19,13 @@ }, "description": "Voulez-vous configurer {name} ?" }, + "reauth_confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Mettez \u00e0 jour vos informations de connexion pour {name} ." + }, "user": { "data": { "host": "Nom d'h\u00f4te ou adresse IP", diff --git a/homeassistant/components/fritzbox/translations/it.json b/homeassistant/components/fritzbox/translations/it.json index a420b3f6de7..6aba6a007d7 100644 --- a/homeassistant/components/fritzbox/translations/it.json +++ b/homeassistant/components/fritzbox/translations/it.json @@ -5,7 +5,7 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "no_devices_found": "Nessun dispositivo trovato sulla rete", "not_supported": "Collegato a AVM FRITZ!Box ma non \u00e8 in grado di controllare i dispositivi Smart Home.", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida" diff --git a/homeassistant/components/fritzbox/translations/ko.json b/homeassistant/components/fritzbox/translations/ko.json index b04b6905284..dfdcc0ad4eb 100644 --- a/homeassistant/components/fritzbox/translations/ko.json +++ b/homeassistant/components/fritzbox/translations/ko.json @@ -1,9 +1,14 @@ { "config": { "abort": { - "already_configured": "\uc774 AVM FRITZ!Box \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "AVM FRITZ!Box \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", - "not_supported": "AVM FRITZ!Box \uc5d0 \uc5f0\uacb0\ub418\uc5c8\uc9c0\ub9cc \uc2a4\ub9c8\ud2b8 \ud648 \uae30\uae30\ub97c \uc81c\uc5b4\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_supported": "AVM FRITZ!Box \uc5d0 \uc5f0\uacb0\ub418\uc5c8\uc9c0\ub9cc \uc2a4\ub9c8\ud2b8 \ud648 \uae30\uae30\ub97c \uc81c\uc5b4\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "flow_title": "AVM FRITZ!Box: {name}", "step": { @@ -14,6 +19,12 @@ }, "description": "{name} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" }, + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, "user": { "data": { "host": "\ud638\uc2a4\ud2b8", diff --git a/homeassistant/components/fritzbox/translations/nl.json b/homeassistant/components/fritzbox/translations/nl.json index b72374547bc..9bfe2ef6be6 100644 --- a/homeassistant/components/fritzbox/translations/nl.json +++ b/homeassistant/components/fritzbox/translations/nl.json @@ -3,7 +3,12 @@ "abort": { "already_configured": "Deze AVM FRITZ!Box is al geconfigureerd.", "already_in_progress": "AVM FRITZ!Box configuratie is al bezig.", - "not_supported": "Verbonden met AVM FRITZ! Box, maar het kan geen Smart Home-apparaten bedienen." + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "not_supported": "Verbonden met AVM FRITZ! Box, maar het kan geen Smart Home-apparaten bedienen.", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" }, "flow_title": "AVM FRITZ!Box: {name}", "step": { @@ -14,6 +19,12 @@ }, "description": "Wilt u {name} instellen?" }, + "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + }, "user": { "data": { "host": "Host of IP-adres", diff --git a/homeassistant/components/fritzbox/translations/ru.json b/homeassistant/components/fritzbox/translations/ru.json index 50146b490ba..8cd77671bd8 100644 --- a/homeassistant/components/fritzbox/translations/ru.json +++ b/homeassistant/components/fritzbox/translations/ru.json @@ -8,7 +8,7 @@ "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "AVM FRITZ!Box: {name}", "step": { diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index ab296c84121..01a43f7c7ef 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( + ATTR_NAME, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -27,7 +28,6 @@ from .const import ( DEFAULT_USERNAME, DOMAIN, FRITZ_ACTION_GET_INFO, - FRITZ_ATTR_NAME, FRITZ_ATTR_SERIAL_NUMBER, FRITZ_SERVICE_DEVICE_INFO, SERIAL_NUMBER, @@ -119,7 +119,7 @@ class FritzBoxCallMonitorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): phonebook_info = await self.hass.async_add_executor_job( self._fritzbox_phonebook.fph.phonebook_info, phonebook_id ) - return phonebook_info[FRITZ_ATTR_NAME] + return phonebook_info[ATTR_NAME] async def _get_list_of_phonebook_names(self): """Return list of names for all available phonebooks.""" @@ -165,9 +165,7 @@ class FritzBoxCallMonitorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if result != RESULT_SUCCESS: return self.async_abort(reason=result) - if ( # pylint: disable=no-member - self.context["source"] == config_entries.SOURCE_IMPORT - ): + if self.context["source"] == config_entries.SOURCE_IMPORT: self._phonebook_id = user_input[CONF_PHONEBOOK] self._phonebook_name = user_input[CONF_NAME] diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index a71f14401b3..6f0c87f5273 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -15,7 +15,6 @@ ICON_PHONE = "mdi:phone" ATTR_PREFIXES = "prefixes" FRITZ_ACTION_GET_INFO = "GetInfo" -FRITZ_ATTR_NAME = "name" FRITZ_ATTR_SERIAL_NUMBER = "NewSerialNumber" FRITZ_SERVICE_DEVICE_INFO = "DeviceInfo" diff --git a/homeassistant/components/fritzbox_callmonitor/translations/de.json b/homeassistant/components/fritzbox_callmonitor/translations/de.json new file mode 100644 index 00000000000..a26f301a9bd --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/de.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "insufficient_permissions": "Der Benutzer verf\u00fcgt nicht \u00fcber ausreichende Berechtigungen, um auf die Einstellungen der AVM FRITZ!Box und ihre Telefonb\u00fccher zuzugreifen.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "flow_title": "AVM FRITZ! Box-Anrufmonitor: {name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Telefonbuch" + } + }, + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Die Pr\u00e4fixe sind fehlerhaft, bitte das Format \u00fcberpr\u00fcfen." + }, + "step": { + "init": { + "data": { + "prefixes": "Pr\u00e4fixe (kommagetrennte Liste)" + }, + "title": "Pr\u00e4fixe konfigurieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/es.json b/homeassistant/components/fritzbox_callmonitor/translations/es.json new file mode 100644 index 00000000000..4d4aa4cd86b --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/es.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "insufficient_permissions": "El usuario no tiene permisos suficientes para acceder a la configuraci\u00f3n de AVM FRITZ! Box y sus agendas telef\u00f3nicas." + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "flow_title": "Monitor de llamadas de AVM FRITZ! Box: {name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Directorio telef\u00f3nico" + } + }, + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Los prefijos tienen un formato incorrecto, comprueba el formato." + }, + "step": { + "init": { + "data": { + "prefixes": "Prefijos (lista separada por comas)" + }, + "title": "Configurar prefijos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/fr.json b/homeassistant/components/fritzbox_callmonitor/translations/fr.json new file mode 100644 index 00000000000..cde9023273c --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/fr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "insufficient_permissions": "L'utilisateur ne dispose pas des autorisations n\u00e9cessaires pour acc\u00e9der aux param\u00e8tres d'AVM FRITZ! Box et \u00e0 ses r\u00e9pertoires.", + "no_devices_found": "Aucun appreil trouv\u00e9 sur le r\u00e9seau " + }, + "error": { + "invalid_auth": "Authentification invalide" + }, + "flow_title": "Moniteur d'appels AVM FRITZ! Box: {name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Annuaire" + } + }, + "user": { + "data": { + "host": "Hote", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur " + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Les pr\u00e9fixes sont mal form\u00e9s, veuillez v\u00e9rifier leur format." + }, + "step": { + "init": { + "data": { + "prefixes": "Pr\u00e9fixes (liste s\u00e9par\u00e9e par des virgules)" + }, + "title": "Configurer les pr\u00e9fixes" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ko.json b/homeassistant/components/fritzbox_callmonitor/translations/ko.json new file mode 100644 index 00000000000..b8fd442cd03 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/nl.json b/homeassistant/components/fritzbox_callmonitor/translations/nl.json new file mode 100644 index 00000000000..3381ed0d9b2 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ru.json b/homeassistant/components/fritzbox_callmonitor/translations/ru.json index 3eb432532c4..f1bcb18a2f6 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/ru.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/ru.json @@ -6,7 +6,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "AVM FRITZ!Box call monitor: {name}", "step": { diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 130a8d55072..02ff760e574 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -2,6 +2,7 @@ import copy from datetime import timedelta import logging +from typing import Dict from pyfronius import Fronius import voluptuous as vol @@ -130,6 +131,7 @@ class FroniusAdapter: self._name = name self._device = device self._fetched = {} + self._available = True self.sensors = set() self._registered_sensors = set() @@ -145,21 +147,32 @@ class FroniusAdapter: """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.""" - values = {} try: values = await self._update() except ConnectionError: - _LOGGER.error("Failed to update: connection error") + # 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: connection error") + return except ValueError: _LOGGER.error( "Failed to update: invalid response returned." "Maybe the configured device is not supported" ) - - if not values: return + + self._available = True # reset connection failure + attributes = self._fetched # Copy data of current fronius device for key, entry in values.items(): @@ -182,7 +195,7 @@ class FroniusAdapter: for sensor in self._registered_sensors: sensor.async_schedule_update_ha_state(True) - async def _update(self): + async def _update(self) -> Dict: """Return values of interest.""" async def register(self, sensor): @@ -268,6 +281,11 @@ class FroniusTemplateSensor(Entity): """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._name) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 080d786d4e4..bb503ee8673 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -14,7 +14,7 @@ from yarl import URL from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.config import async_hass_config_yaml -from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED +from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv @@ -113,7 +113,6 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" -CONF_MODE = "mode" class Panel: @@ -262,10 +261,10 @@ async def async_setup(hass, config): for path, should_cache in ( ("service_worker.js", False), ("robots.txt", False), - ("onboarding.html", True), - ("static", True), - ("frontend_latest", True), - ("frontend_es5", True), + ("onboarding.html", not is_dev), + ("static", not is_dev), + ("frontend_latest", not is_dev), + ("frontend_es5", not is_dev), ): hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 65a5497d1f9..4d4127fc2f2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,9 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20210127.7"], + "requirements": [ + "home-assistant-frontend==20210302.3" + ], "dependencies": [ "api", "auth", @@ -15,6 +17,8 @@ "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/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index cc0d6bde216..075b73986ff 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -11,4 +11,4 @@ set_theme: example: "dark" reload_themes: - description: Reload themes from yaml configuration. + description: Reload themes from YAML configuration. diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index 6b05c96416f..04a731a7272 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def to_futurenow_level(level): """Convert the given Home Assistant light level (0-255) to FutureNow (0-100).""" - return int((level * 100) / 255) + return round((level * 100) / 255) def to_hass_level(level): diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json index c7880f9b416..59597750ce8 100644 --- a/homeassistant/components/garmin_connect/manifest.json +++ b/homeassistant/components/garmin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "garmin_connect", "name": "Garmin Connect", "documentation": "https://www.home-assistant.io/integrations/garmin_connect", - "requirements": ["garminconnect==0.1.16"], + "requirements": ["garminconnect==0.1.19"], "codeowners": ["@cyberjunky"], "config_flow": true } diff --git a/homeassistant/components/garmin_connect/translations/ko.json b/homeassistant/components/garmin_connect/translations/ko.json index fee07e579fe..4d5330a824f 100644 --- a/homeassistant/components/garmin_connect/translations/ko.json +++ b/homeassistant/components/garmin_connect/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/garmin_connect/translations/ru.json b/homeassistant/components/garmin_connect/translations/ru.json index 69fa96c2a5e..49dd5c5b3bc 100644 --- a/homeassistant/components/garmin_connect/translations/ru.json +++ b/homeassistant/components/garmin_connect/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index c45d6e56425..890c9f8e050 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -116,7 +116,7 @@ class GdacsEvent(GeolocationEvent): @callback def _delete_callback(self): """Remove this entity.""" - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/gdacs/translations/ko.json b/homeassistant/components/gdacs/translations/ko.json index 1aeaf219288..b91d512039a 100644 --- a/homeassistant/components/gdacs/translations/ko.json +++ b/homeassistant/components/gdacs/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 2e798b8cc4b..56b490e165a 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -2,10 +2,7 @@ import asyncio import logging -import aiohttp -import async_timeout -import requests -from requests.auth import HTTPDigestAuth +import httpx import voluptuous as vol from homeassistant.components.camera import ( @@ -25,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.reload import async_setup_reload_service from . import DOMAIN, PLATFORMS @@ -37,8 +34,12 @@ CONF_LIMIT_REFETCH_TO_URL_CHANGE = "limit_refetch_to_url_change" CONF_STILL_IMAGE_URL = "still_image_url" CONF_STREAM_SOURCE = "stream_source" CONF_FRAMERATE = "framerate" +CONF_RTSP_TRANSPORT = "rtsp_transport" +FFMPEG_OPTION_MAP = {CONF_RTSP_TRANSPORT: "rtsp_transport"} +ALLOWED_RTSP_TRANSPORT_PROTOCOLS = {"tcp", "udp", "udp_multicast", "http"} DEFAULT_NAME = "Generic Camera" +GET_IMAGE_TIMEOUT = 10 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -56,6 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( cv.small_float, cv.positive_int ), vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_RTSP_TRANSPORT): vol.In(ALLOWED_RTSP_TRANSPORT_PROTOCOLS), } ) @@ -87,15 +89,19 @@ class GenericCamera(Camera): self._supported_features = SUPPORT_STREAM if self._stream_source else 0 self.content_type = device_info[CONF_CONTENT_TYPE] self.verify_ssl = device_info[CONF_VERIFY_SSL] + if device_info.get(CONF_RTSP_TRANSPORT): + self.stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = device_info[ + CONF_RTSP_TRANSPORT + ] username = device_info.get(CONF_USERNAME) password = device_info.get(CONF_PASSWORD) if username and password: if self._authentication == HTTP_DIGEST_AUTHENTICATION: - self._auth = HTTPDigestAuth(username, password) + self._auth = httpx.DigestAuth(username=username, password=password) else: - self._auth = aiohttp.BasicAuth(username, password=password) + self._auth = httpx.BasicAuth(username=username, password=password) else: self._auth = None @@ -129,40 +135,19 @@ class GenericCamera(Camera): if url == self._last_url and self._limit_refetch: return self._last_image - # aiohttp don't support DigestAuth yet - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - - def fetch(): - """Read image from a URL.""" - try: - response = requests.get( - url, timeout=10, auth=self._auth, verify=self.verify_ssl - ) - return response.content - except requests.exceptions.RequestException as error: - _LOGGER.error( - "Error getting new camera image from %s: %s", self._name, error - ) - return self._last_image - - self._last_image = await self.hass.async_add_executor_job(fetch) - # async - else: - try: - websession = async_get_clientsession( - self.hass, verify_ssl=self.verify_ssl - ) - with async_timeout.timeout(10): - response = await websession.get(url, auth=self._auth) - self._last_image = await response.read() - except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image - except aiohttp.ClientError as err: - _LOGGER.error( - "Error getting new camera image from %s: %s", self._name, err - ) - return self._last_image + try: + async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) + response = await async_client.get( + url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT + ) + response.raise_for_status() + self._last_image = response.content + except httpx.TimeoutException: + _LOGGER.error("Timeout getting camera image from %s", self._name) + return self._last_image + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) + return self._last_image self._last_url = url return self._last_image diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 433e91104ad..5fbdf499146 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_NAME, + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, PRECISION_HALVES, PRECISION_TENTHS, @@ -85,6 +86,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -109,6 +111,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= away_temp = config.get(CONF_AWAY_TEMP) precision = config.get(CONF_PRECISION) unit = hass.config.units.temperature_unit + unique_id = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -128,6 +131,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= away_temp, precision, unit, + unique_id, ) ] ) @@ -153,6 +157,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): away_temp, precision, unit, + unique_id, ): """Initialize the thermostat.""" self._name = name @@ -177,6 +182,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._max_temp = max_temp self._target_temp = target_temp self._unit = unit + self._unique_id = unique_id self._support_flags = SUPPORT_FLAGS if away_temp: self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE @@ -269,6 +275,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Return the name of the thermostat.""" return self._name + @property + def unique_id(self): + """Return the unique id of this thermostat.""" + return self._unique_id + @property def precision(self): """Return the precision of the system.""" diff --git a/homeassistant/components/geniushub/services.yaml b/homeassistant/components/geniushub/services.yaml index 50cd8d7d01e..fa46c1d4c09 100644 --- a/homeassistant/components/geniushub/services.yaml +++ b/homeassistant/components/geniushub/services.yaml @@ -26,3 +26,15 @@ set_zone_override: description: >- The duration of the override. Optional, default 1 hour, maximum 24 hours. example: '{"minutes": 135}' + +set_switch_override: + description: >- + Override switch for a given duration. + fields: + entity_id: + description: The zone's entity_id. + example: switch.study + duration: + description: >- + The duration of the override. Optional, default 1 hour, maximum 24 hours. + example: '{"minutes": 135}' diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index e73468321bd..cb45911d250 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -1,13 +1,29 @@ """Support for Genius Hub switch/outlet devices.""" +from datetime import timedelta + +import voluptuous as vol + from homeassistant.components.switch import DEVICE_CLASS_OUTLET, SwitchEntity +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from . import DOMAIN, GeniusZone - -ATTR_DURATION = "duration" +from . import ATTR_DURATION, DOMAIN, GeniusZone GH_ON_OFF_ZONE = "on / off" +SVC_SET_SWITCH_OVERRIDE = "set_switch_override" + +SET_SWITCH_OVERRIDE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), + ), + } +) + async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None @@ -26,6 +42,15 @@ async def async_setup_platform( ] ) + # Register custom services + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SVC_SET_SWITCH_OVERRIDE, + SET_SWITCH_OVERRIDE_SCHEMA, + "async_turn_on", + ) + class GeniusSwitch(GeniusZone, SwitchEntity): """Representation of a Genius Hub switch.""" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index bb2d86539e9..40386648138 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -144,7 +144,7 @@ class GeoJsonLocationEvent(GeolocationEvent): """Remove this entity.""" self._remove_signal_delete() self._remove_signal_update() - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index aad281da117..9ca8c86e150 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -48,8 +48,11 @@ async def async_attach_trigger(hass, config, action, automation_info): return zone_state = hass.states.get(zone_entity_id) - from_match = condition.zone(hass, zone_state, from_state) - to_match = condition.zone(hass, zone_state, to_state) + + from_match = ( + condition.zone(hass, zone_state, from_state) if from_state else False + ) + to_match = condition.zone(hass, zone_state, to_state) if to_state else False if ( trigger_event == EVENT_ENTER diff --git a/homeassistant/components/geofency/translations/ko.json b/homeassistant/components/geofency/translations/ko.json index 8bab38a8a34..fd49c57e249 100644 --- a/homeassistant/components/geofency/translations/ko.json +++ b/homeassistant/components/geofency/translations/ko.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Geofency \uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/geofency/translations/nl.json b/homeassistant/components/geofency/translations/nl.json index 763d903a8ba..59ed1cf6b5b 100644 --- a/homeassistant/components/geofency/translations/nl.json +++ b/homeassistant/components/geofency/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om locaties naar Home Assistant te sturen, moet u de Webhook-functie instellen in Geofency.\n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Methode: POST \n\n Zie [de documentatie]({docs_url}) voor meer informatie." diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index ed0b9f9f714..718b4c06b9c 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -102,7 +102,7 @@ class GeonetnzQuakesEvent(GeolocationEvent): @callback def _delete_callback(self): """Remove this entity.""" - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/geonetnz_quakes/translations/ko.json b/homeassistant/components/geonetnz_quakes/translations/ko.json index b231629e856..277aa945792 100644 --- a/homeassistant/components/geonetnz_quakes/translations/ko.json +++ b/homeassistant/components/geonetnz_quakes/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_volcano/translations/ko.json b/homeassistant/components/geonetnz_volcano/translations/ko.json index 26e83789e8f..1aeaf219288 100644 --- a/homeassistant/components/geonetnz_volcano/translations/ko.json +++ b/homeassistant/components/geonetnz_volcano/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 117eada036b..ab354e319a8 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -1,7 +1,6 @@ """Constants for GIOS integration.""" from datetime import timedelta -ATTR_NAME = "name" ATTR_STATION = "station" CONF_STATION_ID = "station_id" DEFAULT_NAME = "GIOŚ" diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 99e54edfeaf..468e22260b5 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -3,7 +3,7 @@ "name": "GIOŚ", "documentation": "https://www.home-assistant.io/integrations/gios", "codeowners": ["@bieniu"], - "requirements": ["gios==0.1.4"], + "requirements": ["gios==0.1.5"], "config_flow": true, "quality_scale": "platinum" } diff --git a/homeassistant/components/gios/translations/ko.json b/homeassistant/components/gios/translations/ko.json index 2ad64efadc1..7895dafe8ce 100644 --- a/homeassistant/components/gios/translations/ko.json +++ b/homeassistant/components/gios/translations/ko.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "\uc774 \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc5d0 \ub300\ud55c GIO\u015a \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { - "cannot_connect": "GIO\u015a \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_sensors_data": "\uc774 \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc5d0 \ub300\ud55c \uc13c\uc11c \ub370\uc774\ud130\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "wrong_station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID \uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "step": { "user": { "data": { - "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc758 \uc774\ub984", + "name": "\uc774\ub984", "station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID" }, "description": "\ud3f4\ub780\ub4dc \ud658\uacbd\uccad (GIO\u015a) \ub300\uae30\uc9c8 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ub3c4\uc6c0\uc774 \ud544\uc694\ud55c \uacbd\uc6b0 https://www.home-assistant.io/integrations/gios \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 312e726b91d..80d05ae1b9c 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -228,18 +228,25 @@ class GitHubData: self.stargazers = repo.stargazers_count self.forks = repo.forks_count - open_issues = repo.get_issues(state="open", sort="created") - if open_issues is not None: - self.open_issue_count = open_issues.totalCount - if open_issues.totalCount > 0: - self.latest_open_issue_url = open_issues[0].html_url - open_pull_requests = repo.get_pulls(state="open", sort="created") if open_pull_requests is not None: self.pull_request_count = open_pull_requests.totalCount if open_pull_requests.totalCount > 0: self.latest_open_pr_url = open_pull_requests[0].html_url + open_issues = repo.get_issues(state="open", sort="created") + if open_issues is not None: + if self.pull_request_count is None: + self.open_issue_count = open_issues.totalCount + else: + # pull requests are treated as issues too so we need to reduce the received count + self.open_issue_count = ( + open_issues.totalCount - self.pull_request_count + ) + + if open_issues.totalCount > 0: + self.latest_open_issue_url = open_issues[0].html_url + latest_commit = repo.get_commits()[0] self.latest_commit_sha = latest_commit.sha self.latest_commit_message = latest_commit.commit.message diff --git a/homeassistant/components/glances/translations/ko.json b/homeassistant/components/glances/translations/ko.json index 336c9f3b3e5..47f24d2edf1 100644 --- a/homeassistant/components/glances/translations/ko.json +++ b/homeassistant/components/glances/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "wrong_version": "\ud574\ub2f9 \ubc84\uc804\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4 (2 \ub610\ub294 3\ub9cc \uc9c0\uc6d0)" }, "step": { @@ -14,9 +14,9 @@ "name": "\uc774\ub984", "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec Glances \uc2dc\uc2a4\ud15c\uc5d0 \uc5f0\uacb0", + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", - "verify_ssl": "\uc2dc\uc2a4\ud15c \uc778\uc99d \ud655\uc778", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778", "version": "Glances API \ubc84\uc804 (2 \ub610\ub294 3)" }, "title": "Glances \uc124\uce58\ud558\uae30" diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index 0a1b8909f54..bd59cd5e7f5 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Goal Zero Yeti", - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host ip from your router. DHCP must be set up in your router settings for the device to ensure the host ip does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host IP from your router. DHCP must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" diff --git a/homeassistant/components/goalzero/translations/ca.json b/homeassistant/components/goalzero/translations/ca.json index 2d301d5ef07..ac4c2a696e2 100644 --- a/homeassistant/components/goalzero/translations/ca.json +++ b/homeassistant/components/goalzero/translations/ca.json @@ -14,7 +14,7 @@ "host": "Amfitri\u00f3", "name": "Nom" }, - "description": "En primer lloc, has de baixar-te l'aplicaci\u00f3 Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegueix les instruccions per connectar el teu Yeti a la teva xarxa Wifi. A continuaci\u00f3, has d'obtenir la IP d'amfitri\u00f3 del teu encaminador (router). Cal que aquest tingui la configuraci\u00f3 DHCP activada per al teu dispositiu per aix\u00ed garantir que la IP no canvi\u00ef. Si cal, consulta el manual del teu encaminador.", + "description": "En primer lloc, has de baixar-te l'aplicaci\u00f3 Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegueix les instruccions per connectar el Yeti a la xarxa Wifi. A continuaci\u00f3, has d'obtenir la IP d'amfitri\u00f3 del teu router. Cal que aquest tingui la configuraci\u00f3 DHCP activada per al teu dispositiu, per aix\u00ed garantir que la IP no canvi\u00ef. Si cal, consulta el manual del router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/en.json b/homeassistant/components/goalzero/translations/en.json index 08c823e2ad2..25aa32e4b75 100644 --- a/homeassistant/components/goalzero/translations/en.json +++ b/homeassistant/components/goalzero/translations/en.json @@ -14,7 +14,7 @@ "host": "Host", "name": "Name" }, - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host ip from your router. DHCP must be set up in your router settings for the device to ensure the host ip does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host IP from your router. DHCP must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/et.json b/homeassistant/components/goalzero/translations/et.json index 4479ba8669e..74f84f1d72b 100644 --- a/homeassistant/components/goalzero/translations/et.json +++ b/homeassistant/components/goalzero/translations/et.json @@ -14,7 +14,7 @@ "host": "", "name": "Nimi" }, - "description": "Alustuseks peate alla laadima rakenduse Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\n Yeti Wifi-v\u00f5rguga \u00fchendamiseks j\u00e4rgige juhiseid. Seej\u00e4rel hankige oma ruuterilt host IP. DHCP peab olema ruuteri seadetes seadistatud, et tagada, et host-IP ei muutuks. Vaadake ruuteri kasutusjuhendit.", + "description": "Alustuseks pead alla laadima rakenduse Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\n Yeti Wifi-v\u00f5rguga \u00fchendamiseks j\u00e4rgi juhiseid. Seej\u00e4rel hangi oma ruuterilt host IP. DHCP peab olema ruuteri seadetes seadistatud, et tagada, et host-IP ei muutuks. Vaata ruuteri kasutusjuhendit.", "title": "" } } diff --git a/homeassistant/components/goalzero/translations/it.json b/homeassistant/components/goalzero/translations/it.json index 10df269d59a..24f04a0bafe 100644 --- a/homeassistant/components/goalzero/translations/it.json +++ b/homeassistant/components/goalzero/translations/it.json @@ -14,7 +14,7 @@ "host": "Host", "name": "Nome" }, - "description": "Innanzitutto, devi scaricare l'app Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\nSegui le istruzioni per connettere il tuo Yeti alla tua rete Wifi. Quindi ottieni l'ip host dal tuo router. Il DHCP deve essere configurato nelle impostazioni del router affinch\u00e9 il dispositivo assicuri che l'ip host non cambi. Fare riferimento al manuale utente del router.", + "description": "Innanzitutto, devi scaricare l'app Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\nSegui le istruzioni per connettere il tuo Yeti alla tua rete Wifi. Quindi ottieni l'ip host dal tuo router. Il DHCP deve essere configurato nelle impostazioni del router affinch\u00e9 assicuri che l'ip host non cambi. Fare riferimento al manuale utente del router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/ko.json b/homeassistant/components/goalzero/translations/ko.json new file mode 100644 index 00000000000..f15f5827448 --- /dev/null +++ b/homeassistant/components/goalzero/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/nl.json b/homeassistant/components/goalzero/translations/nl.json index 86958670d70..4d9b5a397dd 100644 --- a/homeassistant/components/goalzero/translations/nl.json +++ b/homeassistant/components/goalzero/translations/nl.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "host": "Host", "name": "Naam" }, "description": "Eerst moet u de Goal Zero-app downloaden: https://www.goalzero.com/product-features/yeti-app/ \n\n Volg de instructies om je Yeti te verbinden met je wifi-netwerk. Haal dan de host-ip van uw router. DHCP moet zijn ingesteld in uw routerinstellingen voor het apparaat om ervoor te zorgen dat het host-ip niet verandert. Raadpleeg de gebruikershandleiding van uw router." diff --git a/homeassistant/components/goalzero/translations/no.json b/homeassistant/components/goalzero/translations/no.json index 1aaf27d1b09..4ae6f564a99 100644 --- a/homeassistant/components/goalzero/translations/no.json +++ b/homeassistant/components/goalzero/translations/no.json @@ -14,7 +14,7 @@ "host": "Vert", "name": "Navn" }, - "description": "F\u00f8rst m\u00e5 du laste ned appen Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\n F\u00f8lg instruksjonene for \u00e5 koble Yeti til Wifi-nettverket. S\u00e5 f\u00e5 verts-ip fra ruteren din. DHCP m\u00e5 v\u00e6re satt opp i ruteren innstillinger for enheten for \u00e5 sikre at verts-IP ikke endres. Se brukerh\u00e5ndboken til ruteren.", + "description": "F\u00f8rst m\u00e5 du laste ned appen Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\n F\u00f8lg instruksjonene for \u00e5 koble Yeti til Wifi-nettverket. S\u00e5 f\u00e5 verts-IP-en fra ruteren din. DHCP m\u00e5 v\u00e6re satt opp i ruteren innstillinger for enheten for \u00e5 sikre at verts-IP ikke endres. Se ruteren din.", "title": "" } } diff --git a/homeassistant/components/gogogate2/translations/ko.json b/homeassistant/components/gogogate2/translations/ko.json index 55b32812bfa..dc37928db76 100644 --- a/homeassistant/components/gogogate2/translations/ko.json +++ b/homeassistant/components/gogogate2/translations/ko.json @@ -15,7 +15,7 @@ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, "description": "\uc544\ub798\uc5d0 \ud544\uc218 \uc815\ubcf4\ub97c \uc81c\uacf5\ud574\uc8fc\uc138\uc694.", - "title": "GogoGate2 \uc124\uce58\ud558\uae30" + "title": "GogoGate2 \ub610\ub294 iSmartGate \uc124\uce58\ud558\uae30" } } } diff --git a/homeassistant/components/gogogate2/translations/nl.json b/homeassistant/components/gogogate2/translations/nl.json index ad8e894d093..5418735ec07 100644 --- a/homeassistant/components/gogogate2/translations/nl.json +++ b/homeassistant/components/gogogate2/translations/nl.json @@ -15,7 +15,7 @@ "username": "Gebruikersnaam" }, "description": "Geef hieronder de vereiste informatie op.", - "title": "Stel GogoGate2 in" + "title": "Stel GogoGate2 of iSmartGate in" } } } diff --git a/homeassistant/components/gogogate2/translations/ru.json b/homeassistant/components/gogogate2/translations/ru.json index 0c8f14f65f4..43e9f7a1b2f 100644 --- a/homeassistant/components/gogogate2/translations/ru.json +++ b/homeassistant/components/gogogate2/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 78ea1616f99..b46d48848da 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -15,7 +15,14 @@ import voluptuous as vol from voluptuous.error import Error as VoluptuousError import yaml -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_NAME, + CONF_OFFSET, +) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id @@ -30,12 +37,8 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_TRACK_NEW = "track_new_calendar" CONF_CAL_ID = "cal_id" -CONF_DEVICE_ID = "device_id" -CONF_NAME = "name" -CONF_ENTITIES = "entities" CONF_TRACK = "track" CONF_SEARCH = "search" -CONF_OFFSET = "offset" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_MAX_RESULTS = "max_results" diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 6448e035171..2fcde78354b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -11,17 +11,14 @@ from homeassistant.components.calendar import ( calculate_offset, is_offset_reached, ) +from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.helpers.entity import generate_entity_id from homeassistant.util import Throttle, dt from . import ( CONF_CAL_ID, - CONF_DEVICE_ID, - CONF_ENTITIES, CONF_IGNORE_AVAILABILITY, CONF_MAX_RESULTS, - CONF_NAME, - CONF_OFFSET, CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 6df116effa5..859f1b33296 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/google", "requirements": [ "google-api-python-client==1.6.4", - "httplib2==0.18.1", + "httplib2==0.19.0", "oauth2client==4.0.0" ], "codeowners": [] diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 8f4ee3b51c4..00c09242517 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Dict import voluptuous as vol # Typing imports -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -35,7 +35,6 @@ from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, is _LOGGER = logging.getLogger(__name__) CONF_ALLOW_UNLOCK = "allow_unlock" -CONF_API_KEY = "api_key" ENTITY_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b5dc2afd3e2..8b0bde09010 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1276,6 +1276,7 @@ class FanSpeedTrait(_Trait): return { "availableFanSpeeds": {"speeds": speeds, "ordered": True}, "reversible": reversible, + "supportsFanSpeedPercent": True, } def query_attributes(self): @@ -1289,9 +1290,11 @@ class FanSpeedTrait(_Trait): response["currentFanSpeedSetting"] = speed if domain == fan.DOMAIN: speed = attrs.get(fan.ATTR_SPEED) + percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 if speed is not None: response["on"] = speed != fan.SPEED_OFF response["currentFanSpeedSetting"] = speed + response["currentFanSpeedPercent"] = percent return response async def execute(self, command, data, params, challenge): @@ -1309,13 +1312,20 @@ class FanSpeedTrait(_Trait): context=data.context, ) if domain == fan.DOMAIN: + service_params = { + ATTR_ENTITY_ID: self.state.entity_id, + } + if "fanSpeedPercent" in params: + service = fan.SERVICE_SET_PERCENTAGE + service_params[fan.ATTR_PERCENTAGE] = params["fanSpeedPercent"] + else: + service = fan.SERVICE_SET_SPEED + service_params[fan.ATTR_SPEED] = params["fanSpeed"] + await self.hass.services.async_call( fan.DOMAIN, - fan.SERVICE_SET_SPEED, - { - ATTR_ENTITY_ID: self.state.entity_id, - fan.ATTR_SPEED: params["fanSpeed"], - }, + service, + service_params, blocking=True, context=data.context, ) @@ -1675,17 +1685,17 @@ class OpenCloseTrait(_Trait): else: position = params["openPercent"] - if features & cover.SUPPORT_SET_POSITION: - service = cover.SERVICE_SET_COVER_POSITION - if position > 0: - should_verify = True - svc_params[cover.ATTR_POSITION] = position - elif position == 0: + if position == 0: service = cover.SERVICE_CLOSE_COVER should_verify = False elif position == 100: service = cover.SERVICE_OPEN_COVER should_verify = True + elif features & cover.SUPPORT_SET_POSITION: + service = cover.SERVICE_SET_COVER_POSITION + if position > 0: + should_verify = True + svc_params[cover.ATTR_POSITION] = position else: raise SmartHomeError( ERR_NOT_SUPPORTED, "No support for partial open close" @@ -1928,12 +1938,10 @@ class TransportControlTrait(_Trait): def query_attributes(self): """Return the attributes of this trait for this entity.""" - return {} async def execute(self, command, data, params, challenge): """Execute a media command.""" - service_attrs = {ATTR_ENTITY_ID: self.state.entity_id} if command == COMMAND_MEDIA_SEEK_RELATIVE: diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 6ffa3a9acd1..b0ae28bf5b1 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -55,6 +55,7 @@ SUPPORTED_LANGUAGES = [ "pl-PL", "pt-BR", "pt-PT", + "ro-RO", "ru-RU", "sk-SK", "sv-SE", diff --git a/homeassistant/components/gpslogger/translations/ko.json b/homeassistant/components/gpslogger/translations/ko.json index a6d95a0e51b..e73d72c06b7 100644 --- a/homeassistant/components/gpslogger/translations/ko.json +++ b/homeassistant/components/gpslogger/translations/ko.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 GPSLogger \uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/gpslogger/translations/nl.json b/homeassistant/components/gpslogger/translations/nl.json index dbf7f47a2e9..d90b648760d 100644 --- a/homeassistant/components/gpslogger/translations/nl.json +++ b/homeassistant/components/gpslogger/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar Home Assistant te verzenden, moet u de webhook-functie instellen in GPSLogger. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n\n Zie [de documentatie] ( {docs_url} ) voor meer informatie." diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index f4e9792a589..fa1f1550e83 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,5 +1,4 @@ """Support for interface with a Gree climate systems.""" -import logging from typing import Optional from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity @@ -8,8 +7,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Gree HVAC device from a config entry.""" diff --git a/homeassistant/components/gree/translations/ko.json b/homeassistant/components/gree/translations/ko.json new file mode 100644 index 00000000000..7011a61f757 --- /dev/null +++ b/homeassistant/components/gree/translations/ko.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/nl.json b/homeassistant/components/gree/translations/nl.json new file mode 100644 index 00000000000..d11896014fd --- /dev/null +++ b/homeassistant/components/gree/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je beginnen met instellen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 697a96649ab..51471739e98 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -7,6 +7,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_NAME, CONF_PORT, + CONF_SENSOR_TYPE, + CONF_SENSORS, CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_STOP, TIME_HOURS, @@ -27,8 +29,6 @@ CONF_NET_METERING = "net_metering" CONF_NUMBER = "number" CONF_PULSE_COUNTERS = "pulse_counters" CONF_SERIAL_NUMBER = "serial_number" -CONF_SENSORS = "sensors" -CONF_SENSOR_TYPE = "sensor_type" CONF_TEMPERATURE_SENSORS = "temperature_sensors" CONF_TIME_UNIT = "time_unit" CONF_VOLTAGE_SENSORS = "voltage" @@ -119,7 +119,6 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: COMPONENT_SCHEMA}, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): """Set up the GreenEye Monitor component.""" - monitors = Monitors() hass.data[DATA_GREENEYE_MONITOR] = monitors diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index c8cd1669f05..f026bdfe3a4 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,6 +1,7 @@ """Support for the sensors in a GreenEye Monitor.""" from homeassistant.const import ( CONF_NAME, + CONF_SENSOR_TYPE, CONF_TEMPERATURE_UNIT, POWER_WATT, TIME_HOURS, @@ -16,7 +17,6 @@ from . import ( CONF_MONITOR_SERIAL_NUMBER, CONF_NET_METERING, CONF_NUMBER, - CONF_SENSOR_TYPE, CONF_TIME_UNIT, DATA_GREENEYE_MONITOR, SENSOR_TYPE_CURRENT, diff --git a/homeassistant/components/griddy/translations/ko.json b/homeassistant/components/griddy/translations/ko.json index a17db380aa0..df9178fab93 100644 --- a/homeassistant/components/griddy/translations/ko.json +++ b/homeassistant/components/griddy/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 32a9bd41014..f185601ce87 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, ATTR_NAME, + CONF_ENTITIES, CONF_ICON, CONF_NAME, ENTITY_MATCH_ALL, @@ -41,7 +42,6 @@ GROUP_ORDER = "group_order" ENTITY_ID_FORMAT = DOMAIN + ".{}" -CONF_ENTITIES = "entities" CONF_ALL = "all" ATTR_ADD_ENTITIES = "add_entities" @@ -345,7 +345,6 @@ async def async_setup(hass, config): async def _process_group_platform(hass, domain, platform): """Process a group platform.""" - current_domain.set(domain) platform.async_describe_on_off_states(hass, hass.data[REG_KEY]) diff --git a/homeassistant/components/group/reproduce_state.py b/homeassistant/components/group/reproduce_state.py index 95915412e4f..adeb0cfee0a 100644 --- a/homeassistant/components/group/reproduce_state.py +++ b/homeassistant/components/group/reproduce_state.py @@ -16,7 +16,6 @@ async def async_reproduce_states( reproduce_options: Optional[Dict[str, Any]] = None, ) -> None: """Reproduce component states.""" - states_copy = [] for state in states: members = get_entity_ids(hass, state.entity_id) diff --git a/homeassistant/components/group/translations/tr.json b/homeassistant/components/group/translations/tr.json index f92785a737f..5a596efdf01 100644 --- a/homeassistant/components/group/translations/tr.json +++ b/homeassistant/components/group/translations/tr.json @@ -2,9 +2,9 @@ "state": { "_": { "closed": "Kapand\u0131", - "home": "[%key:common::state::evde%]", + "home": "Evde", "locked": "Kilitli", - "not_home": "[%key:common::state::evde_degil%]", + "not_home": "D\u0131\u015far\u0131da", "off": "Kapal\u0131", "ok": "Tamam", "on": "A\u00e7\u0131k", diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index 760cf960e43..a9286467afc 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -88,7 +88,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): pin = async_get_pin_from_discovery_hostname(discovery_info["hostname"]) await self._async_set_unique_id(pin) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context[CONF_IP_ADDRESS] = discovery_info["host"] if any( diff --git a/homeassistant/components/guardian/translations/ko.json b/homeassistant/components/guardian/translations/ko.json index da40b674009..d9f70ad2d33 100644 --- a/homeassistant/components/guardian/translations/ko.json +++ b/homeassistant/components/guardian/translations/ko.json @@ -1,8 +1,9 @@ { "config": { "abort": { - "already_configured": "\uc774 Guardian \uae30\uae30\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "Guardian \uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index b2c3fb16831..64680a56bb3 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,55 +1,52 @@ -"""Support for Habitica devices.""" -from collections import namedtuple +"""The habitica integration.""" +import asyncio import logging from habitipy.aio import HabitipyAsync import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_NAME, CONF_API_KEY, CONF_NAME, - CONF_PATH, CONF_SENSORS, CONF_URL, ) -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import ( + ATTR_ARGS, + ATTR_PATH, + CONF_API_USER, + DEFAULT_URL, + DOMAIN, + EVENT_API_CALL_SUCCESS, + SERVICE_API_CALL, +) +from .sensor import SENSORS_TYPES + _LOGGER = logging.getLogger(__name__) -CONF_API_USER = "api_user" - -DEFAULT_URL = "https://habitica.com" -DOMAIN = "habitica" - -ST = SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) - -SENSORS_TYPES = { - "name": ST("Name", None, "", ["profile", "name"]), - "hp": ST("HP", "mdi:heart", "HP", ["stats", "hp"]), - "maxHealth": ST("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), - "mp": ST("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), - "maxMP": ST("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), - "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), - "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), - "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), - "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), - "class": ST("Class", "mdi:sword", "", ["stats", "class"]), -} - -INSTANCE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_API_USER): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): vol.All( - cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))] - ), - } +INSTANCE_SCHEMA = vol.All( + cv.deprecated(CONF_SENSORS), + vol.Schema( + { + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_USER): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): vol.All( + cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))] + ), + } + ), ) -has_unique_values = vol.Schema(vol.Unique()) +has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name # because we want a handy alias @@ -73,14 +70,9 @@ def has_all_unique_users_names(value): INSTANCE_LIST_SCHEMA = vol.All( cv.ensure_list, has_all_unique_users, has_all_unique_users_names, [INSTANCE_SCHEMA] ) - CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -SERVICE_API_CALL = "api_call" -ATTR_NAME = CONF_NAME -ATTR_PATH = CONF_PATH -ATTR_ARGS = "args" -EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" +PLATFORMS = ["sensor"] SERVICE_API_CALL_SCHEMA = vol.Schema( { @@ -91,12 +83,25 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Habitica service.""" + configs = config.get(DOMAIN, []) - conf = config[DOMAIN] - data = hass.data[DOMAIN] = {} - websession = async_get_clientsession(hass) + for conf in configs: + if conf.get(CONF_URL) is None: + conf[CONF_URL] = DEFAULT_URL + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up habitica from a config entry.""" class HAHabitipyAsync(HabitipyAsync): """Closure API class to hold session.""" @@ -104,28 +109,6 @@ async def async_setup(hass, config): def __call__(self, **kwargs): return super().__call__(websession, **kwargs) - for instance in conf: - url = instance[CONF_URL] - username = instance[CONF_API_USER] - password = instance[CONF_API_KEY] - name = instance.get(CONF_NAME) - config_dict = {"url": url, "login": username, "password": password} - api = HAHabitipyAsync(config_dict) - user = await api.user.get() - if name is None: - name = user["profile"]["name"] - data[name] = api - if CONF_SENSORS in instance: - hass.async_create_task( - discovery.async_load_platform( - hass, - "sensor", - DOMAIN, - {"name": name, "sensors": instance[CONF_SENSORS]}, - config, - ) - ) - async def handle_api_call(call): name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] @@ -147,7 +130,50 @@ async def async_setup(hass, config): EVENT_API_CALL_SUCCESS, {"name": name, "path": path, "data": data} ) - hass.services.async_register( - DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA - ) + data = hass.data.setdefault(DOMAIN, {}) + config = config_entry.data + websession = async_get_clientsession(hass) + url = config[CONF_URL] + username = config[CONF_API_USER] + password = config[CONF_API_KEY] + name = config.get(CONF_NAME) + config_dict = {"url": url, "login": username, "password": password} + api = HAHabitipyAsync(config_dict) + user = await api.user.get() + if name is None: + name = user["profile"]["name"] + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, CONF_NAME: name}, + ) + data[config_entry.entry_id] = api + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + if not hass.services.has_service(DOMAIN, SERVICE_API_CALL): + hass.services.async_register( + DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + if len(hass.config_entries.async_entries(DOMAIN)) == 1: + hass.services.async_remove(DOMAIN, SERVICE_API_CALL) + return unload_ok diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py new file mode 100644 index 00000000000..6e3311ea9b5 --- /dev/null +++ b/homeassistant/components/habitica/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for habitica integration.""" +import logging +from typing import Dict + +from aiohttp import ClientResponseError +from habitipy.aio import HabitipyAsync +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_API_USER, DEFAULT_URL, DOMAIN # pylint: disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_USER): str, + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_URL, default=DEFAULT_URL): str, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: Dict[str, str] +) -> Dict[str, str]: + """Validate the user input allows us to connect.""" + + websession = async_get_clientsession(hass) + api = HabitipyAsync( + conf={ + "login": data[CONF_API_USER], + "password": data[CONF_API_KEY], + "url": data[CONF_URL] or DEFAULT_URL, + } + ) + try: + await api.user.get(session=websession) + return { + "title": f"{data.get('name', 'Default username')}", + CONF_API_USER: data[CONF_API_USER], + } + except ClientResponseError as ex: + raise InvalidAuth() from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for habitica.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors = {"base": "invalid_credentials"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors = {"base": "unknown"} + else: + await self.async_set_unique_id(info[CONF_API_USER]) + 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, + description_placeholders={}, + ) + + async def async_step_import(self, import_data): + """Import habitica config from configuration.yaml.""" + return await self.async_step_user(import_data) + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py new file mode 100644 index 00000000000..02a46334c7a --- /dev/null +++ b/homeassistant/components/habitica/const.py @@ -0,0 +1,13 @@ +"""Constants for the habitica integration.""" + +from homeassistant.const import CONF_PATH + +CONF_API_USER = "api_user" + +DEFAULT_URL = "https://habitica.com" +DOMAIN = "habitica" + +SERVICE_API_CALL = "api_call" +ATTR_PATH = CONF_PATH +ATTR_ARGS = "args" +EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 50664d862ad..0779a2d3248 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -1,7 +1,8 @@ { - "domain": "habitica", - "name": "Habitica", - "documentation": "https://www.home-assistant.io/integrations/habitica", - "requirements": ["habitipy==0.2.0"], - "codeowners": [] + "domain": "habitica", + "name": "Habitica", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/habitica", + "requirements": ["habitipy==0.2.0"], + "codeowners": ["@ASMfreaK", "@leikoilja"] } diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index f885aa832c7..29e494d89ee 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -1,25 +1,82 @@ """Support for Habitica sensors.""" +from collections import namedtuple from datetime import timedelta +import logging -from homeassistant.components import habitica +from aiohttp import ClientResponseError + +from homeassistant.const import CONF_NAME, HTTP_TOO_MANY_REQUESTS from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +ST = SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) -async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the habitica platform.""" - if discovery_info is None: - return +SENSORS_TYPES = { + "name": ST("Name", None, "", ["profile", "name"]), + "hp": ST("HP", "mdi:heart", "HP", ["stats", "hp"]), + "maxHealth": ST("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), + "mp": ST("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), + "maxMP": ST("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), + "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), + "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), + "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), + "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), + "class": ST("Class", "mdi:sword", "", ["stats", "class"]), +} - name = discovery_info[habitica.CONF_NAME] - sensors = discovery_info[habitica.CONF_SENSORS] - sensor_data = HabitipyData(hass.data[habitica.DOMAIN][name]) +TASKS_TYPES = { + "habits": ST("Habits", "mdi:clipboard-list-outline", "n_of_tasks", ["habits"]), + "dailys": ST("Dailys", "mdi:clipboard-list-outline", "n_of_tasks", ["dailys"]), + "todos": ST("TODOs", "mdi:clipboard-list-outline", "n_of_tasks", ["todos"]), + "rewards": ST("Rewards", "mdi:clipboard-list-outline", "n_of_tasks", ["rewards"]), +} + +TASKS_MAP_ID = "id" +TASKS_MAP = { + "repeat": "repeat", + "challenge": "challenge", + "group": "group", + "frequency": "frequency", + "every_x": "everyX", + "streak": "streak", + "counter_up": "counterUp", + "counter_down": "counterDown", + "next_due": "nextDue", + "yester_daily": "yesterDaily", + "completed": "completed", + "collapse_checklist": "collapseChecklist", + "type": "type", + "notes": "notes", + "tags": "tags", + "value": "value", + "priority": "priority", + "start_date": "startDate", + "days_of_month": "daysOfMonth", + "weeks_of_month": "weeksOfMonth", + "created_at": "createdAt", + "text": "text", + "is_due": "isDue", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the habitica sensors.""" + + entities = [] + name = config_entry.data[CONF_NAME] + sensor_data = HabitipyData(hass.data[DOMAIN][config_entry.entry_id]) await sensor_data.update() - async_add_devices( - [HabitipySensor(name, sensor, sensor_data) for sensor in sensors], True - ) + for sensor_type in SENSORS_TYPES: + entities.append(HabitipySensor(name, sensor_type, sensor_data)) + for task_type in TASKS_TYPES: + entities.append(HabitipyTaskSensor(name, task_type, sensor_data)) + async_add_entities(entities, True) class HabitipyData: @@ -29,11 +86,43 @@ class HabitipyData: """Habitica API user data cache.""" self.api = api self.data = None + self.tasks = {} @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self): """Get a new fix from Habitica servers.""" - self.data = await self.api.user.get() + try: + self.data = await self.api.user.get() + except ClientResponseError as error: + if error.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "Sensor data update for %s has too many API requests." + " Skipping the update.", + DOMAIN, + ) + else: + _LOGGER.error( + "Count not update sensor data for %s (%s)", + DOMAIN, + error, + ) + + for task_type in TASKS_TYPES: + try: + self.tasks[task_type] = await self.api.tasks.user.get(type=task_type) + except ClientResponseError as error: + if error.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "Sensor data update for %s has too many API requests." + " Skipping the update.", + DOMAIN, + ) + else: + _LOGGER.error( + "Count not update sensor data for %s (%s)", + DOMAIN, + error, + ) class HabitipySensor(Entity): @@ -43,7 +132,7 @@ class HabitipySensor(Entity): """Initialize a generic Habitica sensor.""" self._name = name self._sensor_name = sensor_name - self._sensor_type = habitica.SENSORS_TYPES[sensor_name] + self._sensor_type = SENSORS_TYPES[sensor_name] self._state = None self._updater = updater @@ -63,7 +152,7 @@ class HabitipySensor(Entity): @property def name(self): """Return the name of the sensor.""" - return f"{habitica.DOMAIN}_{self._name}_{self._sensor_name}" + return f"{DOMAIN}_{self._name}_{self._sensor_name}" @property def state(self): @@ -74,3 +163,63 @@ class HabitipySensor(Entity): def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._sensor_type.unit + + +class HabitipyTaskSensor(Entity): + """A Habitica task sensor.""" + + def __init__(self, name, task_name, updater): + """Initialize a generic Habitica task.""" + self._name = name + self._task_name = task_name + self._task_type = TASKS_TYPES[task_name] + self._state = None + self._updater = updater + + async def async_update(self): + """Update Condition and Forecast.""" + await self._updater.update() + all_tasks = self._updater.tasks + for element in self._task_type.path: + tasks_length = len(all_tasks[element]) + self._state = tasks_length + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._task_type.icon + + @property + def name(self): + """Return the name of the task.""" + return f"{DOMAIN}_{self._name}_{self._task_name}" + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of all user tasks.""" + if self._updater.tasks is not None: + all_received_tasks = self._updater.tasks + for element in self._task_type.path: + received_tasks = all_received_tasks[element] + attrs = {} + + # Map tasks to TASKS_MAP + for received_task in received_tasks: + task_id = received_task[TASKS_MAP_ID] + task = {} + for map_key, map_value in TASKS_MAP.items(): + value = received_task.get(map_value) + if value: + task[map_key] = value + attrs[task_id] = task + return attrs + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._task_type.unit diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 20794b4c47b..6fa8589ba4c 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,6 +1,6 @@ # Describes the format for Habitica service api_call: - description: Call Habitica api + description: Call Habitica API fields: name: description: Habitica's username to call for @@ -9,5 +9,5 @@ api_call: description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks" example: '["tasks", "user", "post"]' args: - description: Any additional json or url parameter arguments. See apidoc mentioned for path. Example uses same api endpoint + description: Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint example: '{"text": "Use API from Home Assistant", "type": "todo"}' diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json new file mode 100644 index 00000000000..868d024b02e --- /dev/null +++ b/homeassistant/components/habitica/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} diff --git a/homeassistant/components/habitica/translations/ca.json b/homeassistant/components/habitica/translations/ca.json new file mode 100644 index 00000000000..675fc33db8c --- /dev/null +++ b/homeassistant/components/habitica/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "api_user": "ID d'usuari de l'API d'Habitica", + "name": "Substitueix el nom d'usuari d'Habitica. S'utilitzar\u00e0 per a crides de servei", + "url": "URL" + }, + "description": "Connecta el perfil d'Habitica per permetre el seguiment del teu perfil i tasques d'usuari. Tingues en compte que l'api_id i l'api_key els has d'obtenir des de https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/cs.json b/homeassistant/components/habitica/translations/cs.json new file mode 100644 index 00000000000..5ebfec2cf12 --- /dev/null +++ b/homeassistant/components/habitica/translations/cs.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_credentials": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/de.json b/homeassistant/components/habitica/translations/de.json new file mode 100644 index 00000000000..04f985946fb --- /dev/null +++ b/homeassistant/components/habitica/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_credentials": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "url": "URL" + } + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/en.json b/homeassistant/components/habitica/translations/en.json new file mode 100644 index 00000000000..ffbbd2de840 --- /dev/null +++ b/homeassistant/components/habitica/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "api_user": "Habitica\u2019s API user ID", + "name": "Override for Habitica\u2019s username. Will be used for service calls", + "url": "URL" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/es.json b/homeassistant/components/habitica/translations/es.json new file mode 100644 index 00000000000..afdbb6666ad --- /dev/null +++ b/homeassistant/components/habitica/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_user": "ID de usuario de la API de Habitica", + "name": "Anular el nombre de usuario de Habitica. Se utilizar\u00e1 para llamadas de servicio.", + "url": "URL" + }, + "description": "Conecta tu perfil de Habitica para permitir la supervisi\u00f3n del perfil y las tareas de tu usuario. Ten en cuenta que api_id y api_key deben obtenerse de https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/et.json b/homeassistant/components/habitica/translations/et.json new file mode 100644 index 00000000000..cfc2bcf898c --- /dev/null +++ b/homeassistant/components/habitica/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti", + "api_user": "Habitica API kasutaja ID", + "name": "Habitica kasutajanime alistamine. Kasutatakse teenuste kutsumiseks", + "url": "URL" + }, + "description": "\u00dchenda oma Habitica profiil, et saaksid j\u00e4lgida oma kasutaja profiili ja \u00fclesandeid. Pane t\u00e4hele, et api_id ja api_key tuleb hankida aadressilt https://habitica.com/user/settings/api" + } + } + }, + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/fr.json b/homeassistant/components/habitica/translations/fr.json new file mode 100644 index 00000000000..00fcd36a508 --- /dev/null +++ b/homeassistant/components/habitica/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 API", + "api_user": "ID utilisateur de l'API d'Habitica", + "name": "Remplacez le nom d\u2019utilisateur d\u2019Habitica. Sera utilis\u00e9 pour les appels de service", + "url": "URL" + }, + "description": "Connectez votre profil Habitica pour permettre la surveillance du profil et des t\u00e2ches de votre utilisateur. Notez que api_id et api_key doivent \u00eatre obtenus de https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/it.json b/homeassistant/components/habitica/translations/it.json new file mode 100644 index 00000000000..2bef21519b6 --- /dev/null +++ b/homeassistant/components/habitica/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "api_user": "ID utente API di Habitica", + "name": "Sostituisci il nome utente di Habitica. Verr\u00e0 utilizzato per le chiamate di servizio", + "url": "URL" + }, + "description": "Collega il tuo profilo Habitica per consentire il monitoraggio del profilo e delle attivit\u00e0 dell'utente. Nota che api_id e api_key devono essere ottenuti da https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/ko.json b/homeassistant/components/habitica/translations/ko.json new file mode 100644 index 00000000000..3fd04a4477b --- /dev/null +++ b/homeassistant/components/habitica/translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_credentials": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "url": "URL \uc8fc\uc18c" + } + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/nl.json b/homeassistant/components/habitica/translations/nl.json new file mode 100644 index 00000000000..13a4fd6c729 --- /dev/null +++ b/homeassistant/components/habitica/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_credentials": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "url": "URL" + } + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/no.json b/homeassistant/components/habitica/translations/no.json new file mode 100644 index 00000000000..cdb72d3c3d6 --- /dev/null +++ b/homeassistant/components/habitica/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "api_user": "Habiticas API-bruker-ID", + "name": "Overstyr for Habiticas brukernavn. Blir brukt til serviceanrop", + "url": "URL" + }, + "description": "Koble til Habitica-profilen din for \u00e5 tillate overv\u00e5king av brukerens profil og oppgaver. Merk at api_id og api_key m\u00e5 hentes fra https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/pl.json b/homeassistant/components/habitica/translations/pl.json new file mode 100644 index 00000000000..f06f1a0e1aa --- /dev/null +++ b/homeassistant/components/habitica/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "api_user": "Identyfikator API u\u017cytkownika Habitica", + "name": "Nadpisanie nazwy u\u017cytkownika Habitica. B\u0119dzie u\u017cywany do wywo\u0142a\u0144 serwisowych.", + "url": "URL" + }, + "description": "Po\u0142\u0105cz sw\u00f3j profil Habitica, aby umo\u017cliwi\u0107 monitorowanie profilu i zada\u0144 u\u017cytkownika. Pami\u0119taj, \u017ce api_id i api_key musz\u0105 zosta\u0107 pobrane z https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/ru.json b/homeassistant/components/habitica/translations/ru.json new file mode 100644 index 00000000000..4899cd1e43b --- /dev/null +++ b/homeassistant/components/habitica/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "\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": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "api_user": "ID \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f API Habitica", + "name": "\u041f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435 \u0438\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f Habitica. \u0411\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0432\u044b\u0437\u043e\u0432\u043e\u0432 \u0441\u043b\u0443\u0436\u0431", + "url": "URL-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u043f\u0440\u043e\u0444\u0438\u043b\u044c Habitica, \u0447\u0442\u043e\u0431\u044b \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438 \u0437\u0430\u0434\u0430\u0447\u0438 \u0412\u0430\u0448\u0435\u0433\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f. \u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435 \u0432\u043d\u0438\u043c\u0430\u043d\u0438\u0435, \u0447\u0442\u043e api_id \u0438 api_key \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u044b \u0441 https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/tr.json b/homeassistant/components/habitica/translations/tr.json new file mode 100644 index 00000000000..f77cc77798c --- /dev/null +++ b/homeassistant/components/habitica/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/zh-Hant.json b/homeassistant/components/habitica/translations/zh-Hant.json new file mode 100644 index 00000000000..001682b5c88 --- /dev/null +++ b/homeassistant/components/habitica/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "api_user": "Habitica \u4e4b API \u4f7f\u7528\u8005 ID", + "name": "\u8986\u5beb Habitica \u4f7f\u7528\u8005\u540d\u7a31\u3001\u7528\u4ee5\u670d\u52d9\u547c\u53eb", + "url": "\u7db2\u5740" + }, + "description": "\u9023\u7dda\u81f3 Habitica \u8a2d\u5b9a\u6a94\u4ee5\u4f9b\u76e3\u63a7\u500b\u4eba\u8a2d\u5b9a\u8207\u4efb\u52d9\u3002\u6ce8\u610f\uff1a\u5fc5\u9808\u7531 https://habitica.com/user/settings/api \u53d6\u5f97 api_id \u8207 api_key" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 0508bf48703..3a78e9bbe80 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -1,14 +1,9 @@ """Constants for Google Hangouts Component.""" -import logging - import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(".") - - DOMAIN = "hangouts" CONF_2FA = "2fa" diff --git a/homeassistant/components/hangouts/translations/et.json b/homeassistant/components/hangouts/translations/et.json index a587edcd632..6bcc19d2043 100644 --- a/homeassistant/components/hangouts/translations/et.json +++ b/homeassistant/components/hangouts/translations/et.json @@ -5,9 +5,9 @@ "unknown": "Tundmatu viga" }, "error": { - "invalid_2fa": "Vale 2-teguriline autentimine, proovige uuesti.", - "invalid_2fa_method": "Kehtetu 2FA meetod (kontrollige telefoni teel).", - "invalid_login": "Vale Kasutajanimi, palun proovige uuesti." + "invalid_2fa": "Vale 2-teguriline autentimine, proovi uuesti.", + "invalid_2fa_method": "Kehtetu 2FA meetod (kontrolli telefoni teel).", + "invalid_login": "Vale kasutajanimi, palun proovi uuesti." }, "step": { "2fa": { diff --git a/homeassistant/components/hangouts/translations/ko.json b/homeassistant/components/hangouts/translations/ko.json index 51bd857e358..3c23effaf4f 100644 --- a/homeassistant/components/hangouts/translations/ko.json +++ b/homeassistant/components/hangouts/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google \ud589\uc544\uc6c3\uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "invalid_2fa": "2\ub2e8\uacc4 \uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 6ba63ee0f81..8445c7be937 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1,16 +1,20 @@ """The Logitech Harmony Hub integration.""" import asyncio +import logging from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS from .data import HarmonyData +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: dict): """Set up the Logitech Harmony Hub component.""" @@ -40,6 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = data + await _migrate_old_unique_ids(hass, entry.entry_id, data) + entry.add_update_listener(_update_listener) for component in PLATFORMS: @@ -50,6 +56,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True +async def _migrate_old_unique_ids( + hass: HomeAssistant, entry_id: str, data: HarmonyData +): + names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} + + @callback + def _async_migrator(entity_entry: entity_registry.RegistryEntry): + # Old format for switches was {remote_unique_id}-{activity_name} + # New format is activity_{activity_id} + parts = entity_entry.unique_id.split("-", 1) + if len(parts) > 1: # old format + activity_name = parts[1] + activity_id = names_to_ids.get(activity_name) + + if activity_id is not None: + _LOGGER.info( + "Migrating unique_id from [%s] to [%s]", + entity_entry.unique_id, + activity_id, + ) + return {"new_unique_id": f"activity_{activity_id}"} + + return None + + await entity_registry.async_migrate_entries(hass, entry_id, _async_migrator) + + @callback def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index e01febbef43..899edeb8a91 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -89,7 +89,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._host_already_configured(parsed_url.hostname): return self.async_abort(reason="already_configured") - # pylint: disable=no-member self.context["title_placeholders"] = {"name": friendly_name} self.harmony_config = { diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 6c3ad874fa9..340596ff1ef 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -22,30 +22,25 @@ class HarmonyData(HarmonySubscriberMixin): self._name = name self._unique_id = unique_id self._available = False + self._client = None + self._address = address - callbacks = { - "config_updated": self._config_updated, - "connect": self._connected, - "disconnect": self._disconnected, - "new_activity_starting": self._activity_starting, - "new_activity": self._activity_started, - } - self._client = HarmonyClient( - ip_address=address, callbacks=ClientCallbackType(**callbacks) - ) + @property + def activities(self): + """List of all non-poweroff activity objects.""" + activity_infos = self._client.config.get("activity", []) + return [ + info + for info in activity_infos + if info["label"] is not None and info["label"] != ACTIVITY_POWER_OFF + ] @property def activity_names(self): """Names of all the remotes activities.""" - activity_infos = self._client.config.get("activity", []) + activity_infos = self.activities activities = [activity["label"] for activity in activity_infos] - # Remove both ways of representing PowerOff - if None in activities: - activities.remove(None) - if ACTIVITY_POWER_OFF in activities: - activities.remove(ACTIVITY_POWER_OFF) - return activities @property @@ -101,6 +96,18 @@ class HarmonyData(HarmonySubscriberMixin): async def connect(self) -> bool: """Connect to the Harmony Hub.""" _LOGGER.debug("%s: Connecting", self._name) + + callbacks = { + "config_updated": self._config_updated, + "connect": self._connected, + "disconnect": self._disconnected, + "new_activity_starting": self._activity_starting, + "new_activity": self._activity_started, + } + self._client = HarmonyClient( + ip_address=self._address, callbacks=ClientCallbackType(**callbacks) + ) + try: if not await self._client.connect(): _LOGGER.warning("%s: Unable to connect to HUB", self._name) @@ -109,6 +116,7 @@ class HarmonyData(HarmonySubscriberMixin): except aioexc.TimeOut: _LOGGER.warning("%s: Connection timed-out", self._name) return False + return True async def shutdown(self): @@ -155,10 +163,12 @@ class HarmonyData(HarmonySubscriberMixin): ) return + await self.async_lock_start_activity() try: await self._client.start_activity(activity_id) except aioexc.TimeOut: _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) + self.async_unlock_start_activity() async def async_power_off(self): """Start the PowerOff activity.""" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 7509f3d4f4d..eb7a99fffa8 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -2,7 +2,7 @@ "domain": "harmony", "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", - "requirements": ["aioharmony==0.2.6"], + "requirements": ["aioharmony==0.2.7"], "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"], "ssdp": [ { diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py index d3bed33d560..b2652cc43d1 100644 --- a/homeassistant/components/harmony/subscriber.py +++ b/homeassistant/components/harmony/subscriber.py @@ -1,5 +1,6 @@ """Mixin class for handling harmony callback subscriptions.""" +import asyncio import logging from typing import Any, Callable, NamedTuple, Optional @@ -29,6 +30,17 @@ class HarmonySubscriberMixin: super().__init__() self._hass = hass self._subscriptions = [] + self._activity_lock = asyncio.Lock() + + async def async_lock_start_activity(self): + """Acquire the lock.""" + await self._activity_lock.acquire() + + @callback + def async_unlock_start_activity(self): + """Release the lock.""" + if self._activity_lock.locked(): + self._activity_lock.release() @callback def async_subscribe(self, update_callbacks: HarmonyCallback) -> Callable: @@ -51,11 +63,13 @@ class HarmonySubscriberMixin: def _connected(self, _=None) -> None: _LOGGER.debug("connected") + self.async_unlock_start_activity() self._available = True self._call_callbacks("connected") def _disconnected(self, _=None) -> None: _LOGGER.debug("disconnected") + self.async_unlock_start_activity() self._available = False self._call_callbacks("disconnected") @@ -65,6 +79,7 @@ class HarmonySubscriberMixin: def _activity_started(self, activity_info: tuple) -> None: _LOGGER.debug("activity %s started", activity_info) + self.async_unlock_start_activity() self._call_callbacks("activity_started", activity_info) def _call_callbacks(self, callback_func_name: str, argument: tuple = None): diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 2832872c2ef..5aac145e749 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -15,12 +15,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up harmony activity switches.""" data = hass.data[DOMAIN][entry.entry_id] - activities = data.activity_names + activities = data.activities switches = [] for activity in activities: _LOGGER.debug("creating switch for activity: %s", activity) - name = f"{entry.data[CONF_NAME]} {activity}" + name = f"{entry.data[CONF_NAME]} {activity['label']}" switches.append(HarmonyActivitySwitch(name, activity, data)) async_add_entities(switches, True) @@ -29,11 +29,12 @@ async def async_setup_entry(hass, entry, async_add_entities): class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): """Switch representation of a Harmony activity.""" - def __init__(self, name: str, activity: str, data: HarmonyData): + def __init__(self, name: str, activity: dict, data: HarmonyData): """Initialize HarmonyActivitySwitch class.""" super().__init__() self._name = name - self._activity = activity + self._activity_name = activity["label"] + self._activity_id = activity["id"] self._data = data @property @@ -44,7 +45,7 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): @property def unique_id(self): """Return the unique id.""" - return f"{self._data.unique_id}-{self._activity}" + return f"activity_{self._activity_id}" @property def device_info(self): @@ -55,7 +56,7 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): def is_on(self): """Return if the current activity is the one for this switch.""" _, activity_name = self._data.current_activity - return activity_name == self._activity + return activity_name == self._activity_name @property def should_poll(self): @@ -69,7 +70,7 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): async def async_turn_on(self, **kwargs): """Start this activity.""" - await self._data.async_start_activity(self._activity) + await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs): """Stop this activity.""" diff --git a/homeassistant/components/harmony/translations/ko.json b/homeassistant/components/harmony/translations/ko.json index 528f5e9cc7e..026e751b788 100644 --- a/homeassistant/components/harmony/translations/ko.json +++ b/homeassistant/components/harmony/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Logitech Harmony Hub: {name}", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e8b874b2334..5b40d7142f1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -24,15 +24,26 @@ from homeassistant.util.dt import utcnow from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view -from .const import ATTR_DISCOVERY +from .const import ( + ATTR_ADDON, + ATTR_ADDONS, + ATTR_DISCOVERY, + ATTR_FOLDERS, + ATTR_HOMEASSISTANT, + ATTR_INPUT, + ATTR_PASSWORD, + ATTR_SNAPSHOT, + DOMAIN, +) from .discovery import async_setup_discovery_view from .handler import HassIO, HassioAPIError, api_data from .http import HassIOView from .ingress import async_setup_ingress_view +from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) -DOMAIN = "hassio" + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -62,17 +73,10 @@ SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" -ATTR_ADDON = "addon" -ATTR_INPUT = "input" -ATTR_SNAPSHOT = "snapshot" -ATTR_ADDONS = "addons" -ATTR_FOLDERS = "folders" -ATTR_HOMEASSISTANT = "homeassistant" -ATTR_PASSWORD = "password" SCHEMA_NO_DATA = vol.Schema({}) -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): cv.slug}) +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): cv.string}) SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} @@ -101,6 +105,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( } ) + MAP_SERVICE_API = { SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), @@ -164,6 +169,18 @@ async def async_uninstall_addon(hass: HomeAssistantType, slug: str) -> dict: return await hassio.send_command(command, timeout=60) +@bind_hass +@api_data +async def async_update_addon(hass: HomeAssistantType, slug: str) -> dict: + """Update add-on. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/addons/{slug}/update" + return await hassio.send_command(command, timeout=None) + + @bind_hass @api_data async def async_start_addon(hass: HomeAssistantType, slug: str) -> dict: @@ -213,6 +230,21 @@ async def async_get_addon_discovery_info( return next((addon for addon in discovered_addons if addon["addon"] == slug), None) +@bind_hass +@api_data +async def async_create_snapshot( + hass: HomeAssistantType, payload: dict, partial: bool = False +) -> dict: + """Create a full or partial snapshot. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + snapshot_type = "partial" if partial else "full" + command = f"/snapshots/new/{snapshot_type}" + return await hassio.send_command(command, payload=payload, timeout=None) + + @callback @bind_hass def get_info(hass): @@ -290,6 +322,8 @@ async def async_setup(hass, config): _LOGGER.error("Missing %s environment variable", env) return False + async_load_websocket_api(hass) + host = os.environ["HASSIO"] websession = hass.helpers.aiohttp_client.async_get_clientsession() hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 9e44b961a1c..a48c8b4d05b 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -5,10 +5,10 @@ import logging from aiohttp import web from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST +from homeassistant.const import ATTR_ICON, HTTP_BAD_REQUEST from homeassistant.helpers.typing import HomeAssistantType -from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_ICON, ATTR_PANELS, ATTR_TITLE +from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_PANELS, ATTR_TITLE from .handler import HassioAPIError _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index ffccb325395..b2878c8143f 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,21 +1,40 @@ """Hass.io const variables.""" -ATTR_ADDONS = "addons" -ATTR_DISCOVERY = "discovery" +DOMAIN = "hassio" + ATTR_ADDON = "addon" -ATTR_NAME = "name" -ATTR_SERVICE = "service" -ATTR_CONFIG = "config" -ATTR_UUID = "uuid" -ATTR_USERNAME = "username" -ATTR_PASSWORD = "password" -ATTR_PANELS = "panels" -ATTR_ENABLE = "enable" -ATTR_TITLE = "title" -ATTR_ICON = "icon" +ATTR_ADDONS = "addons" ATTR_ADMIN = "admin" +ATTR_CONFIG = "config" +ATTR_DATA = "data" +ATTR_DISCOVERY = "discovery" +ATTR_ENABLE = "enable" +ATTR_FOLDERS = "folders" +ATTR_HOMEASSISTANT = "homeassistant" +ATTR_INPUT = "input" +ATTR_PANELS = "panels" +ATTR_PASSWORD = "password" +ATTR_SNAPSHOT = "snapshot" +ATTR_TITLE = "title" +ATTR_USERNAME = "username" +ATTR_UUID = "uuid" +ATTR_WS_EVENT = "event" +ATTR_ENDPOINT = "endpoint" +ATTR_METHOD = "method" +ATTR_TIMEOUT = "timeout" + X_HASSIO = "X-Hassio-Key" X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" + + +WS_TYPE = "type" +WS_ID = "id" + +WS_TYPE_API = "supervisor/api" +WS_TYPE_EVENT = "supervisor/event" +WS_TYPE_SUBSCRIBE = "supervisor/subscribe" + +EVENT_SUPERVISOR_EVENT = "supervisor_event" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index f3337254f1a..c682e34c301 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -6,17 +6,10 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant.components.http import HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START from homeassistant.core import callback -from .const import ( - ATTR_ADDON, - ATTR_CONFIG, - ATTR_DISCOVERY, - ATTR_NAME, - ATTR_SERVICE, - ATTR_UUID, -) +from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID from .handler import HassioAPIError _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 8afdcc633bf..3570a857c55 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -1,110 +1,101 @@ -addon_install: - description: Install a Hass.io docker add-on. - fields: - addon: - description: The add-on slug. - example: core_ssh - version: - description: Optional or it will be use the latest version. - example: "0.2" - addon_start: - description: Start a Hass.io docker add-on. + name: Start add-on + description: Start add-on. fields: addon: + name: Add-on + required: true description: The add-on slug. example: core_ssh + selector: + addon: addon_restart: - description: Restart a Hass.io docker add-on. + name: Restart add-on. + description: Restart add-on. fields: addon: + name: Add-on + required: true description: The add-on slug. example: core_ssh + selector: + addon: addon_stdin: - description: Write data to a Hass.io docker add-on stdin . + name: Write data to add-on stdin. + description: Write data to add-on stdin. fields: addon: + name: Add-on + required: true description: The add-on slug. example: core_ssh + selector: + addon: addon_stop: - description: Stop a Hass.io docker add-on. + name: Stop add-on. + description: Stop add-on. fields: addon: + name: Add-on + required: true description: The add-on slug. example: core_ssh - -addon_uninstall: - description: Uninstall a Hass.io docker add-on. - fields: - addon: - description: The add-on slug. - example: core_ssh - -addon_update: - description: Update a Hass.io docker add-on. - fields: - addon: - description: The add-on slug. - example: core_ssh - version: - description: Optional or it will be use the latest version. - example: "0.2" - -homeassistant_update: - description: Update the Home Assistant docker image. - fields: - version: - description: Optional or it will be use the latest version. - example: 0.40.1 + selector: + addon: host_reboot: + name: Reboot the host system. description: Reboot the host system. host_shutdown: + name: Poweroff the host system. description: Poweroff the host system. -host_update: - description: Update the host system. - fields: - version: - description: Optional or it will be use the latest version. - example: "0.3" - snapshot_full: + name: Create a full snapshot. description: Create a full snapshot. fields: name: + name: Name description: Optional or it will be the current date and time. example: "Snapshot 1" + selector: + text: password: + name: Password description: Optional password. example: "password" + selector: + text: snapshot_partial: + name: Create a partial snapshot. description: Create a partial snapshot. fields: addons: + name: Add-ons description: Optional list of addon slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: folders: + name: Folders description: Optional list of directories. example: ["homeassistant", "share"] + selector: + object: name: + name: Name description: Optional or it will be the current date and time. example: "Partial Snapshot 1" + selector: + text: password: + name: Password description: Optional password. example: "password" - -supervisor_reload: - description: Reload the Hass.io supervisor. - -supervisor_update: - description: Update the Hass.io supervisor. - fields: - version: - description: Optional or it will be use the latest version. - example: "0.3" + selector: + text: diff --git a/homeassistant/components/hassio/translations/fr.json b/homeassistant/components/hassio/translations/fr.json index 2bb52c3c54c..cef14b258c4 100644 --- a/homeassistant/components/hassio/translations/fr.json +++ b/homeassistant/components/hassio/translations/fr.json @@ -6,6 +6,7 @@ "disk_used": "Taille du disque utilis\u00e9", "docker_version": "Version de Docker", "healthy": "Sain", + "host_os": "Syst\u00e8me d'exploitation h\u00f4te", "installed_addons": "Add-ons install\u00e9s", "supervisor_api": "API du superviseur", "supervisor_version": "Version du supervisor", diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py new file mode 100644 index 00000000000..387aa926489 --- /dev/null +++ b/homeassistant/components/hassio/websocket_api.py @@ -0,0 +1,111 @@ +"""Websocekt API handlers for the hassio integration.""" +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 +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from .const import ( + ATTR_DATA, + ATTR_ENDPOINT, + ATTR_METHOD, + ATTR_TIMEOUT, + ATTR_WS_EVENT, + DOMAIN, + EVENT_SUPERVISOR_EVENT, + WS_ID, + WS_TYPE, + WS_TYPE_API, + WS_TYPE_EVENT, + WS_TYPE_SUBSCRIBE, +) +from .handler import HassIO + +SCHEMA_WEBSOCKET_EVENT = vol.Schema( + {vol.Required(ATTR_WS_EVENT): cv.string}, + extra=vol.ALLOW_EXTRA, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +@callback +def async_load_websocket_api(hass: HomeAssistant): + """Set up the websocket API.""" + websocket_api.async_register_command(hass, websocket_supervisor_event) + websocket_api.async_register_command(hass, websocket_supervisor_api) + websocket_api.async_register_command(hass, websocket_subscribe) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required(WS_TYPE): WS_TYPE_SUBSCRIBE}) +async def websocket_subscribe( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +): + """Subscribe to supervisor events.""" + + @callback + def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg[WS_ID], data)) + + connection.subscriptions[msg[WS_ID]] = async_dispatcher_connect( + hass, EVENT_SUPERVISOR_EVENT, forward_messages + ) + connection.send_message(websocket_api.result_message(msg[WS_ID])) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): WS_TYPE_EVENT, + vol.Required(ATTR_DATA): SCHEMA_WEBSOCKET_EVENT, + } +) +async def websocket_supervisor_event( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +): + """Publish events from the Supervisor.""" + async_dispatcher_send(hass, EVENT_SUPERVISOR_EVENT, msg[ATTR_DATA]) + connection.send_result(msg[WS_ID]) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): WS_TYPE_API, + vol.Required(ATTR_ENDPOINT): cv.string, + vol.Required(ATTR_METHOD): cv.string, + vol.Optional(ATTR_DATA): dict, + vol.Optional(ATTR_TIMEOUT): vol.Any(cv.Number, None), + } +) +async def websocket_supervisor_api( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +): + """Websocket handler to call Supervisor API.""" + supervisor: HassIO = hass.data[DOMAIN] + result = False + try: + result = await supervisor.send_command( + msg[ATTR_ENDPOINT], + method=msg[ATTR_METHOD], + timeout=msg.get(ATTR_TIMEOUT, 10), + payload=msg.get(ATTR_DATA, {}), + ) + except hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err) + connection.send_error( + msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err) + ) + else: + connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {})) diff --git a/homeassistant/components/heos/translations/ko.json b/homeassistant/components/heos/translations/ko.json index fc20a77d7b8..d17cbd0e4b7 100644 --- a/homeassistant/components/heos/translations/ko.json +++ b/homeassistant/components/heos/translations/ko.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index afc6534d0c6..e51e7a067fc 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_MODE, + CONF_API_KEY, CONF_MODE, CONF_NAME, CONF_UNIT_SYSTEM, @@ -35,7 +36,6 @@ CONF_DESTINATION_ENTITY_ID = "destination_entity_id" CONF_ORIGIN_LATITUDE = "origin_latitude" CONF_ORIGIN_LONGITUDE = "origin_longitude" CONF_ORIGIN_ENTITY_ID = "origin_entity_id" -CONF_API_KEY = "api_key" CONF_TRAFFIC_MODE = "traffic_mode" CONF_ROUTE_MODE = "route_mode" CONF_ARRIVAL = "arrival" @@ -148,7 +148,6 @@ async def async_setup_platform( discovery_info: Optional[DiscoveryInfoType] = None, ) -> None: """Set up the HERE travel time platform.""" - api_key = config[CONF_API_KEY] here_client = herepy.RoutingApi(api_key) diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 359f966d119..90c4b6ce8b9 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE, + CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -30,7 +31,6 @@ from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) CONF_IGNORED = "ignored" -CONF_DELAY = "delay" DEFAULT_PORT = 80 DEFAULT_IGNORED = False @@ -139,7 +139,6 @@ class HikvisionData: def __init__(self, hass, url, port, name, username, password): """Initialize the data object.""" - self._url = url self._port = port self._name = name diff --git a/homeassistant/components/hisense_aehw4a1/translations/ko.json b/homeassistant/components/hisense_aehw4a1/translations/ko.json index 27d0ff88f6a..491887280c0 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/ko.json +++ b/homeassistant/components/hisense_aehw4a1/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Hisense AEH-W4A1 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Hisense AEH-W4A1 \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 98d625cbb1d..331ab37224f 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -2,7 +2,7 @@ from functools import wraps import logging -from pyhiveapi import Pyhiveapi +from pyhiveapi import Hive import voluptuous as vol from homeassistant.const import ( @@ -13,12 +13,16 @@ from homeassistant.const import ( CONF_USERNAME, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) +ATTR_AVAILABLE = "available" DOMAIN = "hive" DATA_HIVE = "data_hive" SERVICES = ["Heating", "HotWater", "TRV"] @@ -69,28 +73,15 @@ BOOST_HOT_WATER_SCHEMA = vol.Schema( ) -class HiveSession: - """Initiate Hive Session Class.""" - - entity_lookup = {} - core = None - heating = None - hotwater = None - light = None - sensor = None - switch = None - weather = None - attributes = None - trv = None - - -def setup(hass, config): +async def async_setup(hass, config): """Set up the Hive Component.""" - def heating_boost(service): + async def heating_boost(service): """Handle the service call.""" - node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) - if not node_id: + + entity_lookup = hass.data[DOMAIN]["entity_lookup"] + hive_id = entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not hive_id: # log or raise error _LOGGER.error("Cannot boost entity id entered") return @@ -98,12 +89,13 @@ def setup(hass, config): minutes = service.data[ATTR_TIME_PERIOD] temperature = service.data[ATTR_TEMPERATURE] - session.heating.turn_boost_on(node_id, minutes, temperature) + hive.heating.turn_boost_on(hive_id, minutes, temperature) - def hot_water_boost(service): + async def hot_water_boost(service): """Handle the service call.""" - node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) - if not node_id: + entity_lookup = hass.data[DOMAIN]["entity_lookup"] + hive_id = entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not hive_id: # log or raise error _LOGGER.error("Cannot boost entity id entered") return @@ -111,45 +103,41 @@ def setup(hass, config): mode = service.data[ATTR_MODE] if mode == "on": - session.hotwater.turn_boost_on(node_id, minutes) + hive.hotwater.turn_boost_on(hive_id, minutes) elif mode == "off": - session.hotwater.turn_boost_off(node_id) + hive.hotwater.turn_boost_off(hive_id) - session = HiveSession() - session.core = Pyhiveapi() + hive = Hive() - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - update_interval = config[DOMAIN][CONF_SCAN_INTERVAL] + config = {} + config["username"] = config[DOMAIN][CONF_USERNAME] + config["password"] = config[DOMAIN][CONF_PASSWORD] + config["update_interval"] = config[DOMAIN][CONF_SCAN_INTERVAL] - devices = session.core.initialise_api(username, password, update_interval) + devices = await hive.session.startSession(config) if devices is None: _LOGGER.error("Hive API initialization failed") return False - session.sensor = Pyhiveapi.Sensor() - session.heating = Pyhiveapi.Heating() - session.hotwater = Pyhiveapi.Hotwater() - session.light = Pyhiveapi.Light() - session.switch = Pyhiveapi.Switch() - session.weather = Pyhiveapi.Weather() - session.attributes = Pyhiveapi.Attributes() - hass.data[DATA_HIVE] = session + hass.data[DOMAIN][DATA_HIVE] = hive + hass.data[DOMAIN]["entity_lookup"] = {} for ha_type in DEVICETYPES: devicelist = devices.get(DEVICETYPES[ha_type]) if devicelist: - load_platform(hass, ha_type, DOMAIN, devicelist, config) + hass.async_create_task( + async_load_platform(hass, ha_type, DOMAIN, devicelist, config) + ) if ha_type == "climate": - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_BOOST_HEATING, heating_boost, schema=BOOST_HEATING_SCHEMA, ) if ha_type == "water_heater": - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_BOOST_HOT_WATER, hot_water_boost, @@ -163,9 +151,9 @@ def refresh_system(func): """Force update all entities after state change.""" @wraps(func) - def wrapper(self, *args, **kwargs): - func(self, *args, **kwargs) - dispatcher_send(self.hass, DOMAIN) + async def wrapper(self, *args, **kwargs): + await func(self, *args, **kwargs) + async_dispatcher_send(self.hass, DOMAIN) return wrapper @@ -173,20 +161,18 @@ def refresh_system(func): class HiveEntity(Entity): """Initiate Hive Base Class.""" - def __init__(self, session, hive_device): + def __init__(self, hive, hive_device): """Initialize the instance.""" - self.node_id = hive_device["Hive_NodeID"] - self.node_name = hive_device["Hive_NodeName"] - self.device_type = hive_device["HA_DeviceType"] - self.node_device_type = hive_device["Hive_DeviceType"] - self.session = session + self.hive = hive + self.device = hive_device self.attributes = {} - self._unique_id = f"{self.node_id}-{self.device_type}" + self._unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' async def async_added_to_hass(self): """When entity is added to Home Assistant.""" self.async_on_remove( async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - if self.device_type in SERVICES: - self.session.entity_lookup[self.entity_id] = self.node_id + if self.device["hiveType"] in SERVICES: + entity_lookup = self.hass.data[DOMAIN]["entity_lookup"] + entity_lookup[self.entity_id] = self.device["hiveID"] diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 120148a8f81..41f1dacc8f3 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -1,28 +1,41 @@ """Support for the Hive binary sensors.""" +from datetime import timedelta + from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION, DEVICE_CLASS_OPENING, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_SOUND, BinarySensorEntity, ) -from . import DATA_HIVE, DOMAIN, HiveEntity +from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity -DEVICETYPE_DEVICE_CLASS = { - "motionsensor": DEVICE_CLASS_MOTION, +DEVICETYPE = { "contactsensor": DEVICE_CLASS_OPENING, + "motionsensor": DEVICE_CLASS_MOTION, + "Connectivity": DEVICE_CLASS_CONNECTIVITY, + "SMOKE_CO": DEVICE_CLASS_SMOKE, + "DOG_BARK": DEVICE_CLASS_SOUND, + "GLASS_BREAK": DEVICE_CLASS_SOUND, } +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Hive sensor devices.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive Binary Sensor.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - devs.append(HiveBinarySensorEntity(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("binary_sensor") + entities = [] + if devices: + for dev in devices: + entities.append(HiveBinarySensorEntity(hive, dev)) + async_add_entities(entities, True) class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): @@ -41,24 +54,35 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): @property def device_class(self): """Return the class of this sensor.""" - return DEVICETYPE_DEVICE_CLASS.get(self.node_device_type) + return DEVICETYPE.get(self.device["hiveType"]) @property def name(self): """Return the name of the binary sensor.""" - return self.node_name + return self.device["haName"] + + @property + def available(self): + """Return if the device is available.""" + if self.device["hiveType"] != "Connectivity": + return self.device["deviceData"]["online"] + return True @property def device_state_attributes(self): """Show Device Attributes.""" - return self.attributes + return { + ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), + ATTR_MODE: self.attributes.get(ATTR_MODE), + } @property def is_on(self): """Return true if the binary sensor is on.""" - return self.session.sensor.get_state(self.node_id, self.node_device_type) + return self.device["status"]["state"] - def update(self): + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) - self.attributes = self.session.attributes.state_attributes(self.node_id) + await self.hive.session.updateData(self.device) + self.device = await self.hive.sensor.get_sensor(self.device) + self.attributes = self.device.get("attributes", {}) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 33c8fed4eca..f1901147f17 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -1,4 +1,6 @@ """Support for the Hive climate devices.""" +from datetime import timedelta + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, @@ -12,9 +14,9 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import ATTR_AVAILABLE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system HIVE_TO_HASS_STATE = { "SCHEDULE": HVAC_MODE_AUTO, @@ -34,21 +36,27 @@ HIVE_TO_HASS_HVAC_ACTION = { True: CURRENT_HVAC_HEAT, } +TEMP_UNIT = {"C": TEMP_CELSIUS, "F": TEMP_FAHRENHEIT} + SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE SUPPORT_HVAC = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] SUPPORT_PRESET = [PRESET_NONE, PRESET_BOOST] +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Hive climate devices.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive thermostat.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - devs.append(HiveClimateEntity(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("climate") + entities = [] + if devices: + for dev in devices: + entities.append(HiveClimateEntity(hive, dev)) + async_add_entities(entities, True) class HiveClimateEntity(HiveEntity, ClimateEntity): @@ -57,7 +65,8 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): def __init__(self, hive_session, hive_device): """Initialize the Climate device.""" super().__init__(hive_session, hive_device) - self.thermostat_node_id = hive_device["Thermostat_NodeID"] + self.thermostat_node_id = hive_device["device_id"] + self.temperature_type = TEMP_UNIT.get(hive_device["temperatureunit"]) @property def unique_id(self): @@ -77,19 +86,17 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): @property def name(self): """Return the name of the Climate device.""" - friendly_name = "Heating" - if self.node_name is not None: - if self.device_type == "TRV": - friendly_name = self.node_name - else: - friendly_name = f"{self.node_name} {friendly_name}" + return self.device["haName"] - return friendly_name + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"]["online"] @property def device_state_attributes(self): """Show Device Attributes.""" - return self.attributes + return {ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE)} @property def hvac_modes(self): @@ -105,47 +112,42 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): Need to be one of HVAC_MODE_*. """ - return HIVE_TO_HASS_STATE[self.session.heating.get_mode(self.node_id)] + return HIVE_TO_HASS_STATE[self.device["status"]["mode"]] @property def hvac_action(self): """Return current HVAC action.""" - return HIVE_TO_HASS_HVAC_ACTION[ - self.session.heating.operational_status(self.node_id, self.device_type) - ] + return HIVE_TO_HASS_HVAC_ACTION[self.device["status"]["action"]] @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS + return self.temperature_type @property def current_temperature(self): """Return the current temperature.""" - return self.session.heating.current_temperature(self.node_id) + return self.device["status"]["current_temperature"] @property def target_temperature(self): """Return the target temperature.""" - return self.session.heating.get_target_temperature(self.node_id) + return self.device["status"]["target_temperature"] @property def min_temp(self): """Return minimum temperature.""" - return self.session.heating.min_temperature(self.node_id) + return self.device["min_temp"] @property def max_temp(self): """Return the maximum temperature.""" - return self.session.heating.max_temperature(self.node_id) + return self.device["max_temp"] @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" - if ( - self.device_type == "Heating" - and self.session.heating.get_boost(self.node_id) == "ON" - ): + if self.device["status"]["boost"] == "ON": return PRESET_BOOST return None @@ -155,31 +157,30 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): return SUPPORT_PRESET @refresh_system - def set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" new_mode = HASS_TO_HIVE_STATE[hvac_mode] - self.session.heating.set_mode(self.node_id, new_mode) + await self.hive.heating.set_mode(self.device, new_mode) @refresh_system - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" new_temperature = kwargs.get(ATTR_TEMPERATURE) if new_temperature is not None: - self.session.heating.set_target_temperature(self.node_id, new_temperature) + await self.hive.heating.set_target_temperature(self.device, new_temperature) @refresh_system - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode): """Set new preset mode.""" if preset_mode == PRESET_NONE and self.preset_mode == PRESET_BOOST: - self.session.heating.turn_boost_off(self.node_id) + await self.hive.heating.turn_boost_off(self.device) elif preset_mode == PRESET_BOOST: curtemp = round(self.current_temperature * 2) / 2 temperature = curtemp + 0.5 - self.session.heating.turn_boost_on(self.node_id, 30, temperature) + await self.hive.heating.turn_boost_on(self.device, 30, temperature) - def update(self): + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) - self.attributes = self.session.attributes.state_attributes( - self.thermostat_node_id - ) + await self.hive.session.updateData(self.device) + self.device = await self.hive.heating.get_heating(self.device) + self.attributes.update(self.device.get("attributes", {})) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 7659d43aeba..f458c27d019 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -1,4 +1,6 @@ -"""Support for the Hive lights.""" +"""Support for Hive light devices.""" +from datetime import timedelta + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -10,29 +12,29 @@ from homeassistant.components.light import ( ) import homeassistant.util.color as color_util -from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system + +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Hive light devices.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive Light.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - devs.append(HiveDeviceLight(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("light") + entities = [] + if devices: + for dev in devices: + entities.append(HiveDeviceLight(hive, dev)) + async_add_entities(entities, True) class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" - def __init__(self, hive_session, hive_device): - """Initialize the Light device.""" - super().__init__(hive_session, hive_device) - self.light_device_type = hive_device["Hive_Light_DeviceType"] - @property def unique_id(self): """Return unique ID of entity.""" @@ -46,59 +48,56 @@ class HiveDeviceLight(HiveEntity, LightEntity): @property def name(self): """Return the display name of this light.""" - return self.node_name + return self.device["haName"] + + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"]["online"] @property def device_state_attributes(self): """Show Device Attributes.""" - return self.attributes + return { + ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), + ATTR_MODE: self.attributes.get(ATTR_MODE), + } @property def brightness(self): """Brightness of the light (an integer in the range 1-255).""" - return self.session.light.get_brightness(self.node_id) + return self.device["status"]["brightness"] @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - if ( - self.light_device_type == "tuneablelight" - or self.light_device_type == "colourtuneablelight" - ): - return self.session.light.get_min_color_temp(self.node_id) + return self.device.get("min_mireds") @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - if ( - self.light_device_type == "tuneablelight" - or self.light_device_type == "colourtuneablelight" - ): - return self.session.light.get_max_color_temp(self.node_id) + return self.device.get("max_mireds") @property def color_temp(self): """Return the CT color value in mireds.""" - if ( - self.light_device_type == "tuneablelight" - or self.light_device_type == "colourtuneablelight" - ): - return self.session.light.get_color_temp(self.node_id) + return self.device["status"].get("color_temp") @property - def hs_color(self) -> tuple: + def hs_color(self): """Return the hs color value.""" - if self.light_device_type == "colourtuneablelight": - rgb = self.session.light.get_color(self.node_id) + if self.device["status"]["mode"] == "COLOUR": + rgb = self.device["status"].get("hs_color") return color_util.color_RGB_to_hs(*rgb) + return None @property def is_on(self): """Return true if light is on.""" - return self.session.light.get_state(self.node_id) + return self.device["status"]["state"] @refresh_system - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" new_brightness = None new_color_temp = None @@ -116,35 +115,32 @@ class HiveDeviceLight(HiveEntity, LightEntity): get_new_color = kwargs.get(ATTR_HS_COLOR) hue = int(get_new_color[0]) saturation = int(get_new_color[1]) - new_color = (hue, saturation, self.brightness) + new_color = (hue, saturation, 100) - self.session.light.turn_on( - self.node_id, - self.light_device_type, - new_brightness, - new_color_temp, - new_color, + await self.hive.light.turn_on( + self.device, new_brightness, new_color_temp, new_color ) @refresh_system - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self.session.light.turn_off(self.node_id) + await self.hive.light.turn_off(self.device) @property def supported_features(self): """Flag supported features.""" supported_features = None - if self.light_device_type == "warmwhitelight": + if self.device["hiveType"] == "warmwhitelight": supported_features = SUPPORT_BRIGHTNESS - elif self.light_device_type == "tuneablelight": + elif self.device["hiveType"] == "tuneablelight": supported_features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - elif self.light_device_type == "colourtuneablelight": + elif self.device["hiveType"] == "colourtuneablelight": supported_features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR return supported_features - def update(self): + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) - self.attributes = self.session.attributes.state_attributes(self.node_id) + await self.hive.session.updateData(self.device) + self.device = await self.hive.light.get_light(self.device) + self.attributes.update(self.device.get("attributes", {})) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index f8fb9bc8c2a..27f235949bf 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -2,6 +2,11 @@ "domain": "hive", "name": "Hive", "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.2.20.2"], - "codeowners": ["@Rendili", "@KJonline"] -} + "requirements": [ + "pyhiveapi==0.3.4.4" + ], + "codeowners": [ + "@Rendili", + "@KJonline" + ] +} \ No newline at end of file diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 360fb61bfbe..e828dff9b4e 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -1,31 +1,32 @@ -"""Support for the Hive sensors.""" -from homeassistant.const import TEMP_CELSIUS +"""Support for the Hive sesnors.""" + +from datetime import timedelta + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY from homeassistant.helpers.entity import Entity -from . import DATA_HIVE, DOMAIN, HiveEntity +from . import ATTR_AVAILABLE, DATA_HIVE, DOMAIN, HiveEntity -FRIENDLY_NAMES = { - "Hub_OnlineStatus": "Hive Hub Status", - "Hive_OutsideTemperature": "Outside Temperature", -} - -DEVICETYPE_ICONS = { - "Hub_OnlineStatus": "mdi:switch", - "Hive_OutsideTemperature": "mdi:thermometer", +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) +DEVICETYPE = { + "Battery": {"unit": " % ", "type": DEVICE_CLASS_BATTERY}, } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Hive sensor devices.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive Sensor.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - if dev["HA_DeviceType"] in FRIENDLY_NAMES: - devs.append(HiveSensorEntity(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("sensor") + entities = [] + if devices: + for dev in devices: + if dev["hiveType"] in DEVICETYPE: + entities.append(HiveSensorEntity(hive, dev)) + async_add_entities(entities, True) class HiveSensorEntity(HiveEntity, Entity): @@ -42,29 +43,36 @@ class HiveSensorEntity(HiveEntity, Entity): return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} @property - def name(self): - """Return the name of the sensor.""" - return FRIENDLY_NAMES.get(self.device_type) + def available(self): + """Return if sensor is available.""" + return self.device.get("deviceData", {}).get("online") @property - def state(self): - """Return the state of the sensor.""" - if self.device_type == "Hub_OnlineStatus": - return self.session.sensor.hub_online_status(self.node_id) - if self.device_type == "Hive_OutsideTemperature": - return self.session.weather.temperature() + def device_class(self): + """Device class of the entity.""" + return DEVICETYPE[self.device["hiveType"]].get("type") @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self.device_type == "Hive_OutsideTemperature": - return TEMP_CELSIUS + return DEVICETYPE[self.device["hiveType"]].get("unit") @property - def icon(self): - """Return the icon to use.""" - return DEVICETYPE_ICONS.get(self.device_type) + def name(self): + """Return the name of the sensor.""" + return self.device["haName"] - def update(self): + @property + def state(self): + """Return the state of the sensor.""" + return self.device["status"]["state"] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE)} + + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) + await self.hive.session.updateData(self.device) + self.device = await self.hive.sensor.get_sensor(self.device) diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 734581b0db3..8ab820589cf 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -1,19 +1,26 @@ """Support for the Hive switches.""" +from datetime import timedelta + from homeassistant.components.switch import SwitchEntity -from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system + +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Hive switches.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive Switch.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - devs.append(HiveDevicePlug(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("switch") + entities = [] + if devices: + for dev in devices: + entities.append(HiveDevicePlug(hive, dev)) + async_add_entities(entities, True) class HiveDevicePlug(HiveEntity, SwitchEntity): @@ -32,34 +39,44 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): @property def name(self): """Return the name of this Switch device if any.""" - return self.node_name + return self.device["haName"] + + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"].get("online") @property def device_state_attributes(self): """Show Device Attributes.""" - return self.attributes + return { + ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), + ATTR_MODE: self.attributes.get(ATTR_MODE), + } @property def current_power_w(self): """Return the current power usage in W.""" - return self.session.switch.get_power_usage(self.node_id) + return self.device["status"]["power_usage"] @property def is_on(self): """Return true if switch is on.""" - return self.session.switch.get_state(self.node_id) + return self.device["status"]["state"] @refresh_system - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - self.session.switch.turn_on(self.node_id) + if self.device["hiveType"] == "activeplug": + await self.hive.switch.turn_on(self.device) @refresh_system - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self.session.switch.turn_off(self.node_id) + if self.device["hiveType"] == "activeplug": + await self.hive.switch.turn_off(self.device) - def update(self): + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) - self.attributes = self.session.attributes.state_attributes(self.node_id) + await self.hive.session.updateData(self.device) + self.device = await self.hive.switch.get_plug(self.device) diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 693fd6f322b..56e98a690b8 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -1,4 +1,7 @@ """Support for hive water heaters.""" + +from datetime import timedelta + from homeassistant.components.water_heater import ( STATE_ECO, STATE_OFF, @@ -11,22 +14,36 @@ from homeassistant.const import TEMP_CELSIUS from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE +HOTWATER_NAME = "Hot Water" +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) +HIVE_TO_HASS_STATE = { + "SCHEDULE": STATE_ECO, + "ON": STATE_ON, + "OFF": STATE_OFF, +} + +HASS_TO_HIVE_STATE = { + STATE_ECO: "SCHEDULE", + STATE_ON: "MANUAL", + STATE_OFF: "OFF", +} -HIVE_TO_HASS_STATE = {"SCHEDULE": STATE_ECO, "ON": STATE_ON, "OFF": STATE_OFF} -HASS_TO_HIVE_STATE = {STATE_ECO: "SCHEDULE", STATE_ON: "ON", STATE_OFF: "OFF"} SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Hive water heater devices.""" +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Hive Hotwater.""" if discovery_info is None: return - session = hass.data.get(DATA_HIVE) - devs = [] - for dev in discovery_info: - devs.append(HiveWaterHeater(session, dev)) - add_entities(devs) + hive = hass.data[DOMAIN].get(DATA_HIVE) + devices = hive.devices.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(HiveWaterHeater(hive, dev)) + async_add_entities(entities, True) class HiveWaterHeater(HiveEntity, WaterHeaterEntity): @@ -50,9 +67,12 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): @property def name(self): """Return the name of the water heater.""" - if self.node_name is None: - self.node_name = "Hot Water" - return self.node_name + return HOTWATER_NAME + + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"]["online"] @property def temperature_unit(self): @@ -62,7 +82,7 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): @property def current_operation(self): """Return current operation.""" - return HIVE_TO_HASS_STATE[self.session.hotwater.get_mode(self.node_id)] + return HIVE_TO_HASS_STATE[self.device["status"]["current_operation"]] @property def operation_list(self): @@ -70,11 +90,22 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): return SUPPORT_WATER_HEATER @refresh_system - def set_operation_mode(self, operation_mode): + async def async_turn_on(self, **kwargs): + """Turn on hotwater.""" + await self.hive.hotwater.set_mode(self.device, "MANUAL") + + @refresh_system + async def async_turn_off(self, **kwargs): + """Turn on hotwater.""" + await self.hive.hotwater.set_mode(self.device, "OFF") + + @refresh_system + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" new_mode = HASS_TO_HIVE_STATE[operation_mode] - self.session.hotwater.set_mode(self.node_id, new_mode) + await self.hive.hotwater.set_mode(self.device, new_mode) - def update(self): + async def async_update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) + await self.hive.session.updateData(self.device) + self.device = await self.hive.hotwater.get_hotwater(self.device) diff --git a/homeassistant/components/hlk_sw16/translations/ko.json b/homeassistant/components/hlk_sw16/translations/ko.json new file mode 100644 index 00000000000..9ba063c37dd --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/nl.json b/homeassistant/components/hlk_sw16/translations/nl.json index 0569c39321a..8ad15260b0d 100644 --- a/homeassistant/components/hlk_sw16/translations/nl.json +++ b/homeassistant/components/hlk_sw16/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, diff --git a/homeassistant/components/hlk_sw16/translations/ru.json b/homeassistant/components/hlk_sw16/translations/ru.json index 6f71ee41376..9e0db9fcf94 100644 --- a/homeassistant/components/hlk_sw16/translations/ru.json +++ b/homeassistant/components/hlk_sw16/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 8db8afa3a6b..da5f1df20c6 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -7,12 +7,29 @@ import homeconnect from homeconnect.api import HomeConnectError from homeassistant import config_entries, core -from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE, TIME_SECONDS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + CONF_DEVICE, + CONF_ENTITIES, + DEVICE_CLASS_TIMESTAMP, + PERCENTAGE, + TIME_SECONDS, +) from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( + ATTR_AMBIENT, + ATTR_DESC, + ATTR_DEVICE, + ATTR_KEY, + ATTR_SENSOR_TYPE, + ATTR_SIGN, + ATTR_UNIT, + ATTR_VALUE, BSH_ACTIVE_PROGRAM, + BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_STANDBY, SIGNAL_UPDATE_ENTITIES, @@ -71,7 +88,9 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): else: _LOGGER.warning("Appliance type %s not implemented", app.type) continue - devices.append({"device": device, "entities": device.get_entity_info()}) + devices.append( + {CONF_DEVICE: device, CONF_ENTITIES: device.get_entity_info()} + ) self.devices = devices return devices @@ -103,8 +122,10 @@ class HomeConnectDevice: except (HomeConnectError, ValueError): _LOGGER.debug("Unable to fetch active programs. Probably offline") program_active = None - if program_active and "key" in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = {"value": program_active["key"]} + if program_active and ATTR_KEY in program_active: + self.appliance.status[BSH_ACTIVE_PROGRAM] = { + ATTR_VALUE: program_active[ATTR_KEY] + } self.appliance.listen_events(callback=self.event_callback) def event_callback(self, appliance): @@ -129,7 +150,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{"device": self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -144,27 +165,47 @@ class DeviceWithPrograms(HomeConnectDevice): } return [ { - "device": self, - "desc": k, - "unit": unit, - "key": "BSH.Common.Option.{}".format(k.replace(" ", "")), - "icon": icon, - "device_class": device_class, - "sign": sign, + ATTR_DEVICE: self, + ATTR_DESC: k, + ATTR_UNIT: unit, + ATTR_KEY: "BSH.Common.Option.{}".format(k.replace(" ", "")), + ATTR_ICON: icon, + ATTR_DEVICE_CLASS: device_class, + ATTR_SIGN: sign, } for k, (unit, icon, device_class, sign) in sensors.items() ] +class DeviceWithOpState(HomeConnectDevice): + """Device that has an operation state sensor.""" + + def get_opstate_sensor(self): + """Get a list with info about operation state sensors.""" + + return [ + { + ATTR_DEVICE: self, + ATTR_DESC: "Operation State", + ATTR_UNIT: None, + ATTR_KEY: BSH_OPERATION_STATE, + ATTR_ICON: "mdi:state-machine", + ATTR_DEVICE_CLASS: None, + ATTR_SIGN: 1, + } + ] + + class DeviceWithDoor(HomeConnectDevice): """Device that has a door sensor.""" def get_door_entity(self): """Get a dictionary with info about the door binary sensor.""" return { - "device": self, - "desc": "Door", - "device_class": "door", + ATTR_DEVICE: self, + ATTR_DESC: "Door", + ATTR_SENSOR_TYPE: "door", + ATTR_DEVICE_CLASS: "door", } @@ -173,11 +214,7 @@ class DeviceWithLight(HomeConnectDevice): def get_light_entity(self): """Get a dictionary with info about the lighting.""" - return { - "device": self, - "desc": "Light", - "ambient": None, - } + return {ATTR_DEVICE: self, ATTR_DESC: "Light", ATTR_AMBIENT: None} class DeviceWithAmbientLight(HomeConnectDevice): @@ -185,14 +222,40 @@ class DeviceWithAmbientLight(HomeConnectDevice): def get_ambientlight_entity(self): """Get a dictionary with info about the ambient lighting.""" + return {ATTR_DEVICE: self, ATTR_DESC: "AmbientLight", ATTR_AMBIENT: True} + + +class DeviceWithRemoteControl(HomeConnectDevice): + """Device that has Remote Control binary sensor.""" + + def get_remote_control(self): + """Get a dictionary with info about the remote control sensor.""" return { - "device": self, - "desc": "AmbientLight", - "ambient": True, + ATTR_DEVICE: self, + ATTR_DESC: "Remote Control", + ATTR_SENSOR_TYPE: "remote_control", } -class Dryer(DeviceWithDoor, DeviceWithPrograms): +class DeviceWithRemoteStart(HomeConnectDevice): + """Device that has a Remote Start binary sensor.""" + + def get_remote_start(self): + """Get a dictionary with info about the remote start sensor.""" + return { + ATTR_DEVICE: self, + ATTR_DESC: "Remote Start", + ATTR_SENSOR_TYPE: "remote_start", + } + + +class Dryer( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): """Dryer class.""" PROGRAMS = [ @@ -217,16 +280,26 @@ class Dryer(DeviceWithDoor, DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() return { - "binary_sensor": [door_entity], + "binary_sensor": [door_entity, remote_control, remote_start], "switch": program_switches, - "sensor": program_sensors, + "sensor": program_sensors + op_state_sensor, } -class Dishwasher(DeviceWithDoor, DeviceWithAmbientLight, DeviceWithPrograms): +class Dishwasher( + DeviceWithDoor, + DeviceWithAmbientLight, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): """Dishwasher class.""" PROGRAMS = [ @@ -257,16 +330,25 @@ class Dishwasher(DeviceWithDoor, DeviceWithAmbientLight, DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() return { - "binary_sensor": [door_entity], + "binary_sensor": [door_entity, remote_control, remote_start], "switch": program_switches, - "sensor": program_sensors, + "sensor": program_sensors + op_state_sensor, } -class Oven(DeviceWithDoor, DeviceWithPrograms): +class Oven( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): """Oven class.""" PROGRAMS = [ @@ -282,16 +364,25 @@ class Oven(DeviceWithDoor, DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() return { - "binary_sensor": [door_entity], + "binary_sensor": [door_entity, remote_control, remote_start], "switch": program_switches, - "sensor": program_sensors, + "sensor": program_sensors + op_state_sensor, } -class Washer(DeviceWithDoor, DeviceWithPrograms): +class Washer( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): """Washer class.""" PROGRAMS = [ @@ -321,16 +412,19 @@ class Washer(DeviceWithDoor, DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() return { - "binary_sensor": [door_entity], + "binary_sensor": [door_entity, remote_control, remote_start], "switch": program_switches, - "sensor": program_sensors, + "sensor": program_sensors + op_state_sensor, } -class CoffeeMaker(DeviceWithPrograms): +class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" PROGRAMS = [ @@ -354,12 +448,25 @@ class CoffeeMaker(DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() - return {"switch": program_switches, "sensor": program_sensors} + return { + "binary_sensor": [remote_start], + "switch": program_switches, + "sensor": program_sensors + op_state_sensor, + } -class Hood(DeviceWithLight, DeviceWithAmbientLight, DeviceWithPrograms): +class Hood( + DeviceWithLight, + DeviceWithAmbientLight, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): """Hood class.""" PROGRAMS = [ @@ -370,13 +477,17 @@ class Hood(DeviceWithLight, DeviceWithAmbientLight, DeviceWithPrograms): def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() light_entity = self.get_light_entity() ambientlight_entity = self.get_ambientlight_entity() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() return { + "binary_sensor": [remote_control, remote_start], "switch": program_switches, - "sensor": program_sensors, + "sensor": program_sensors + op_state_sensor, "light": [light_entity, ambientlight_entity], } @@ -390,13 +501,19 @@ class FridgeFreezer(DeviceWithDoor): return {"binary_sensor": [door_entity]} -class Hob(DeviceWithPrograms): +class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" + remote_control = self.get_remote_control() + op_state_sensor = self.get_opstate_sensor() program_sensors = self.get_program_sensors() program_switches = self.get_program_switches() - return {"switch": program_switches, "sensor": program_sensors} + return { + "binary_sensor": [remote_control], + "switch": program_switches, + "sensor": program_sensors + op_state_sensor, + } diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 4810231b432..4dc21f2fd58 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -2,8 +2,18 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_ENTITIES -from .const import BSH_DOOR_STATE, DOMAIN +from .const import ( + ATTR_VALUE, + BSH_DOOR_STATE, + BSH_DOOR_STATE_CLOSED, + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_REMOTE_CONTROL_ACTIVATION_STATE, + BSH_REMOTE_START_ALLOWANCE_STATE, + DOMAIN, +) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) @@ -16,7 +26,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: - entity_dicts = device_dict.get("entities", {}).get("binary_sensor", []) + entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("binary_sensor", []) entities += [HomeConnectBinarySensor(**d) for d in entity_dicts] return entities @@ -26,11 +36,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): """Binary sensor for Home Connect.""" - def __init__(self, device, desc, device_class): + def __init__(self, device, desc, sensor_type, device_class=None): """Initialize the entity.""" super().__init__(device, desc) - self._device_class = device_class self._state = None + self._device_class = device_class + self._type = sensor_type + if self._type == "door": + self._update_key = BSH_DOOR_STATE + self._false_value_list = (BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED) + self._true_value_list = [BSH_DOOR_STATE_OPEN] + elif self._type == "remote_control": + self._update_key = BSH_REMOTE_CONTROL_ACTIVATION_STATE + self._false_value_list = [False] + self._true_value_list = [True] + elif self._type == "remote_start": + self._update_key = BSH_REMOTE_START_ALLOWANCE_STATE + self._false_value_list = [False] + self._true_value_list = [True] @property def is_on(self): @@ -44,18 +67,17 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): async def async_update(self): """Update the binary sensor's status.""" - state = self.device.appliance.status.get(BSH_DOOR_STATE, {}) + state = self.device.appliance.status.get(self._update_key, {}) if not state: self._state = None - elif state.get("value") in [ - "BSH.Common.EnumType.DoorState.Closed", - "BSH.Common.EnumType.DoorState.Locked", - ]: + elif state.get(ATTR_VALUE) in self._false_value_list: self._state = False - elif state.get("value") == "BSH.Common.EnumType.DoorState.Open": + elif state.get(ATTR_VALUE) in self._true_value_list: self._state = True else: - _LOGGER.warning("Unexpected value for HomeConnect door state: %s", state) + _LOGGER.warning( + "Unexpected value for HomeConnect %s state: %s", self._type, state + ) self._state = None _LOGGER.debug("Updated, new state: %s", self._state) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 22ce4dba676..438ee5ace16 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -11,6 +11,8 @@ BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" +BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" +BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" @@ -24,5 +26,17 @@ BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" BSH_DOOR_STATE = "BSH.Common.Status.DoorState" +BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" +BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" +BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" + +ATTR_AMBIENT = "ambient" +ATTR_DESC = "desc" +ATTR_DEVICE = "device" +ATTR_KEY = "key" +ATTR_SENSOR_TYPE = "sensor_type" +ATTR_SIGN = "sign" +ATTR_UNIT = "unit" +ATTR_VALUE = "value" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 814e3b0ed03..dc176ba90f2 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -11,9 +11,11 @@ from homeassistant.components.light import ( SUPPORT_COLOR, LightEntity, ) +from homeassistant.const import CONF_ENTITIES import homeassistant.util.color as color_util from .const import ( + ATTR_VALUE, BSH_AMBIENT_LIGHT_BRIGHTNESS, BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, @@ -36,7 +38,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: - entity_dicts = device_dict.get("entities", {}).get("light", []) + entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("light", []) entity_list = [HomeConnectLight(**d) for d in entity_dicts] entities += entity_list return entities @@ -93,9 +95,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): _LOGGER.debug("Switching ambient light on for: %s", self.name) try: await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._key, - True, + self.device.appliance.set_setting, self._key, True ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on ambient light: %s", err) @@ -135,9 +135,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) try: await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._brightness_key, - brightness, + self.device.appliance.set_setting, self._brightness_key, brightness ) except HomeConnectError as err: _LOGGER.error("Error while trying set the brightness: %s", err) @@ -145,9 +143,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): _LOGGER.debug("Switching light on for: %s", self.name) try: await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._key, - True, + self.device.appliance.set_setting, self._key, True ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on light: %s", err) @@ -159,9 +155,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): _LOGGER.debug("Switching light off for: %s", self.name) try: await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._key, - False, + self.device.appliance.set_setting, self._key, False ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn off light: %s", err) @@ -169,9 +163,9 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_update(self): """Update the light's status.""" - if self.device.appliance.status.get(self._key, {}).get("value") is True: + if self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is True: self._state = True - elif self.device.appliance.status.get(self._key, {}).get("value") is False: + elif self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is False: self._state = False else: self._state = None @@ -185,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._hs_color = None self._brightness = None else: - colorvalue = color.get("value")[1:] + colorvalue = color.get(ATTR_VALUE)[1:] rgb = color_util.rgb_hex_to_rgb_list(colorvalue) hsv = color_util.color_RGB_to_hsv(rgb[0], rgb[1], rgb[2]) self._hs_color = [hsv[0], hsv[1]] @@ -197,5 +191,5 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if brightness is None: self._brightness = None else: - self._brightness = ceil((brightness.get("value") - 10) * 255 / 90) + self._brightness = ceil((brightness.get(ATTR_VALUE) - 10) * 255 / 90) _LOGGER.debug("Updated, new brightness: %s", self._brightness) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 0ae5a9fcd36..064ae033fb0 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -3,10 +3,10 @@ from datetime import timedelta import logging -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import CONF_ENTITIES, DEVICE_CLASS_TIMESTAMP import homeassistant.util.dt as dt_util -from .const import DOMAIN +from .const import ATTR_VALUE, BSH_OPERATION_STATE, DOMAIN from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: - entity_dicts = device_dict.get("entities", {}).get("sensor", []) + entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("sensor", []) entities += [HomeConnectSensor(**d) for d in entity_dicts] return entities @@ -51,13 +51,13 @@ class HomeConnectSensor(HomeConnectEntity): return self._state is not None async def async_update(self): - """Update the sensos status.""" + """Update the sensor's status.""" status = self.device.appliance.status if self._key not in status: self._state = None else: if self.device_class == DEVICE_CLASS_TIMESTAMP: - if "value" not in status[self._key]: + if ATTR_VALUE not in status[self._key]: self._state = None elif ( self._state is not None @@ -68,12 +68,17 @@ class HomeConnectSensor(HomeConnectEntity): # already past it, set state to None. self._state = None else: - seconds = self._sign * float(status[self._key]["value"]) + seconds = self._sign * float(status[self._key][ATTR_VALUE]) self._state = ( dt_util.utcnow() + timedelta(seconds=seconds) ).isoformat() else: - self._state = status[self._key].get("value") + self._state = status[self._key].get(ATTR_VALUE) + if self._key == BSH_OPERATION_STATE: + # Value comes back as an enum, we only really care about the + # last part, so split it off + # https://developer.home-connect.com/docs/status/operation_state + self._state = self._state.split(".")[-1] _LOGGER.debug("Updated, new state: %s", self._state) @property diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 346e739e5ff..5e12d724a5e 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -4,8 +4,10 @@ import logging from homeconnect.api import HomeConnectError from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_DEVICE, CONF_ENTITIES from .const import ( + ATTR_VALUE, BSH_ACTIVE_PROGRAM, BSH_OPERATION_STATE, BSH_POWER_ON, @@ -25,9 +27,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: - entity_dicts = device_dict.get("entities", {}).get("switch", []) + entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] - entity_list += [HomeConnectPowerSwitch(device_dict["device"])] + entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] entities += entity_list return entities @@ -78,7 +80,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_update(self): """Update the switch's status.""" state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get("value") == self.program_name: + if state.get(ATTR_VALUE) == self.program_name: self._state = True else: self._state = False @@ -103,9 +105,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): _LOGGER.debug("Tried to switch on %s", self.name) try: await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - BSH_POWER_ON, + self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on device: %s", err) @@ -129,17 +129,17 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): async def async_update(self): """Update the switch's status.""" if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get("value") + self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == BSH_POWER_ON ): self._state = True elif ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get("value") + self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == self.device.power_off_state ): self._state = False elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( - "value", None + ATTR_VALUE, None ) in [ "BSH.Common.EnumType.OperationState.Ready", "BSH.Common.EnumType.OperationState.DelayedStart", @@ -151,7 +151,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ]: self._state = True elif ( - self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get("value") + self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) == "BSH.Common.EnumType.OperationState.Inactive" ): self._state = False diff --git a/homeassistant/components/home_connect/translations/ko.json b/homeassistant/components/home_connect/translations/ko.json index 8d1f5554e7f..425968d1460 100644 --- a/homeassistant/components/home_connect/translations/ko.json +++ b/homeassistant/components/home_connect/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "missing_configuration": "Home Connect \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "create_entry": { - "default": "Home Connect \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "pick_implementation": { diff --git a/homeassistant/components/home_connect/translations/nl.json b/homeassistant/components/home_connect/translations/nl.json index 41b27cc387f..25a81209607 100644 --- a/homeassistant/components/home_connect/translations/nl.json +++ b/homeassistant/components/home_connect/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "missing_configuration": "Het component is niet geconfigureerd. Volg de documentatie." + "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})" }, "create_entry": { "default": "Succesvol geverifieerd" diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index cb3efb0d524..38814d9f902 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -1,49 +1,60 @@ check_config: - description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log. + name: Check configuration + description: + Check the Home Assistant configuration files for errors. Errors will be + displayed in the Home Assistant log. reload_core_config: + name: Reload core configuration description: Reload the core configuration. restart: + name: Restart description: Restart the Home Assistant service. set_location: + name: Set location description: Update the Home Assistant location. fields: latitude: - description: Latitude of your location + name: Latitude + description: Latitude of your location. + required: true example: 32.87336 + selector: + text: longitude: - description: Longitude of your location + name: Longitude + description: Longitude of your location. + required: true example: 117.22743 + selector: + text: stop: + name: Stop description: Stop the Home Assistant service. toggle: - description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. - fields: - entity_id: - description: The entity_id of the device to toggle on/off. - example: light.living_room + name: Generic toggle + description: Generic service to toggle devices on/off under any domain + target: + entity: {} turn_on: - description: Generic service to turn devices on under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. - fields: - entity_id: - description: The entity_id of the device to turn on. - example: light.living_room + name: Generic turn on + description: Generic service to turn devices on under any domain. + target: + entity: {} turn_off: - description: Generic service to turn devices off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. - fields: - entity_id: - description: The entity_id of the device to turn off. - example: light.living_room + name: Generic turn off + description: Generic service to turn devices off under any domain. + target: + entity: {} update_entity: + name: Update entity description: Force one or more entities to update its data - fields: - entity_id: - description: One or multiple entity_ids to update. Can be a list. - example: light.living_room + target: + entity: {} diff --git a/homeassistant/components/homeassistant/translations/nl.json b/homeassistant/components/homeassistant/translations/nl.json index 338a019019f..47b69068ea3 100644 --- a/homeassistant/components/homeassistant/translations/nl.json +++ b/homeassistant/components/homeassistant/translations/nl.json @@ -1,12 +1,15 @@ { "system_health": { "info": { + "arch": "CPU-architectuur", + "chassis": "Chassis", "dev": "Ontwikkeling", "docker": "Docker", "docker_version": "Docker", "hassio": "Supervisor", "host_os": "Home Assistant OS", "installation_type": "Type installatie", + "os_name": "Besturingssysteemfamilie", "os_version": "Versie van het besturingssysteem", "python_version": "Python versie", "supervisor": "Supervisor", diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index b7ab081d266..2bc42c3d063 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,22 +1,21 @@ """Offer event listening automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM from homeassistant.core import HassJob, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template # mypy: allow-untyped-defs CONF_EVENT_TYPE = "event_type" -CONF_EVENT_DATA = "event_data" CONF_EVENT_CONTEXT = "context" TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): "event", - vol.Required(CONF_EVENT_TYPE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EVENT_DATA): dict, - vol.Optional(CONF_EVENT_CONTEXT): dict, + vol.Required(CONF_EVENT_TYPE): vol.All(cv.ensure_list, [cv.template]), + vol.Optional(CONF_EVENT_DATA): vol.All(dict, cv.template_complex), + vol.Optional(CONF_EVENT_CONTEXT): vol.All(dict, cv.template_complex), } ) @@ -32,25 +31,43 @@ async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="event" ): """Listen for events based on configuration.""" - event_types = config.get(CONF_EVENT_TYPE) + variables = None + if automation_info: + variables = automation_info.get("variables") + + template.attach(hass, config[CONF_EVENT_TYPE]) + event_types = template.render_complex( + config[CONF_EVENT_TYPE], variables, limited=True + ) removes = [] event_data_schema = None - if config.get(CONF_EVENT_DATA): + if CONF_EVENT_DATA in config: + # Render the schema input + template.attach(hass, config[CONF_EVENT_DATA]) + event_data = {} + event_data.update( + template.render_complex(config[CONF_EVENT_DATA], variables, limited=True) + ) + # Build the schema event_data_schema = vol.Schema( - { - vol.Required(key): value - for key, value in config.get(CONF_EVENT_DATA).items() - }, + {vol.Required(key): value for key, value in event_data.items()}, extra=vol.ALLOW_EXTRA, ) event_context_schema = None - if config.get(CONF_EVENT_CONTEXT): + if CONF_EVENT_CONTEXT in config: + # Render the schema input + template.attach(hass, config[CONF_EVENT_CONTEXT]) + event_context = {} + event_context.update( + template.render_complex(config[CONF_EVENT_CONTEXT], variables, limited=True) + ) + # Build the schema event_context_schema = vol.Schema( { vol.Required(key): _schema_value(value) - for key, value in config.get(CONF_EVENT_CONTEXT).items() + for key, value in event_context.items() }, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 7cfee8fad93..59f16c41a36 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -73,17 +73,21 @@ async def async_attach_trigger( template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) unsub_track_same = {} - entities_triggered = set() + armed_entities = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) + _variables = {} + if automation_info: + _variables = automation_info.get("variables") or {} + if value_template is not None: value_template.hass = hass def variables(entity_id): """Return a dict with trigger variables.""" - return { + trigger_info = { "trigger": { "platform": "numeric_state", "entity_id": entity_id, @@ -92,17 +96,27 @@ async def async_attach_trigger( "attribute": attribute, } } + return {**_variables, **trigger_info} @callback def check_numeric_state(entity_id, from_s, to_s): - """Return True if criteria are now met.""" - if to_s is None: - return False - + """Return whether the criteria are met, raise ConditionError if unknown.""" return condition.async_numeric_state( hass, to_s, below, above, value_template, variables(entity_id), attribute ) + # Each entity that starts outside the range is already armed (ready to fire). + for entity_id in entity_ids: + try: + if not check_numeric_state(entity_id, None, entity_id): + armed_entities.add(entity_id) + except exceptions.ConditionError as ex: + _LOGGER.warning( + "Error initializing '%s' trigger: %s", + automation_info["name"], + ex, + ) + @callback def state_automation_listener(event): """Listen for state changes and calls action.""" @@ -130,12 +144,27 @@ async def async_attach_trigger( to_s.context, ) - matching = check_numeric_state(entity_id, from_s, to_s) + @callback + def check_numeric_state_no_raise(entity_id, from_s, to_s): + """Return True if the criteria are now met, False otherwise.""" + try: + return check_numeric_state(entity_id, from_s, to_s) + except exceptions.ConditionError: + # This is an internal same-state listener so we just drop the + # error. The same error will be reached and logged by the + # primary async_track_state_change_event() listener. + return False + + try: + matching = check_numeric_state(entity_id, from_s, to_s) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in '%s' trigger: %s", automation_info["name"], ex) + return if not matching: - entities_triggered.discard(entity_id) - elif entity_id not in entities_triggered: - entities_triggered.add(entity_id) + armed_entities.add(entity_id) + elif entity_id in armed_entities: + armed_entities.discard(entity_id) if time_delta: try: @@ -148,7 +177,6 @@ async def async_attach_trigger( automation_info["name"], ex, ) - entities_triggered.discard(entity_id) return unsub_track_same[entity_id] = async_track_same_state( @@ -156,7 +184,7 @@ async def async_attach_trigger( period[entity_id], call_action, entity_ids=entity_id, - async_check_same_func=check_numeric_state, + async_check_same_func=check_numeric_state_no_raise, ) else: call_action() diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 5dd7335f56e..8a03905d98d 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -85,6 +85,10 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) + _variables = {} + if automation_info: + _variables = automation_info.get("variables") or {} + @callback def state_automation_listener(event: Event): """Listen for state changes and calls action.""" @@ -143,7 +147,7 @@ async def async_attach_trigger( call_action() return - variables = { + trigger_info = { "trigger": { "platform": "state", "entity_id": entity, @@ -151,6 +155,7 @@ async def async_attach_trigger( "to_state": to_s, } } + variables = {**_variables, **trigger_info} try: period[entity] = cv.positive_time_period( diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 53fbd7cf8f1..c042872f4cd 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -5,7 +5,7 @@ import logging import os from aiohttp import web -from pyhap.const import CATEGORY_CAMERA, CATEGORY_TELEVISION, STANDALONE_AID +from pyhap.const import STANDALONE_AID import voluptuous as vol from homeassistant.components import zeroconf @@ -42,7 +42,21 @@ from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util import get_local_ip -from .accessories import get_accessory +# pylint: disable=unused-import +from . import ( # noqa: F401 + type_cameras, + type_covers, + type_fans, + type_humidifiers, + type_lights, + type_locks, + type_media_players, + type_security_systems, + type_sensors, + type_switches, + type_thermostats, +) +from .accessories import HomeBridge, HomeDriver, get_accessory from .aidmanager import AccessoryAidStorage from .const import ( AID_STORAGE, @@ -56,6 +70,7 @@ from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_ENTRY_INDEX, + CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, CONF_LINKED_BATTERY_CHARGING_SENSOR, @@ -67,6 +82,7 @@ from .const import ( CONF_ZEROCONF_DEFAULT_INTERFACE, CONFIG_OPTIONS, DEFAULT_AUTO_START, + DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, DEFAULT_PORT, DEFAULT_SAFE_MODE, @@ -83,12 +99,13 @@ from .const import ( UNDO_UPDATE_LISTENER, ) from .util import ( + accessory_friendly_name, dismiss_setup_message, get_persist_fullpath_for_entry_id, - migrate_filesystem_state_data_for_primary_imported_entry_id, port_is_available, remove_state_files_for_entry_id, show_setup_message, + state_needs_accessory_mode, validate_entity_config, ) @@ -224,26 +241,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): port = conf[CONF_PORT] _LOGGER.debug("Begin setup HomeKit for %s", name) - # If the previous instance hasn't cleaned up yet - # we need to wait a bit - if not await hass.async_add_executor_job(port_is_available, port): - _LOGGER.warning("The local port %s is in use", port) - raise ConfigEntryNotReady - - if CONF_ENTRY_INDEX in conf and conf[CONF_ENTRY_INDEX] == 0: - _LOGGER.debug("Migrating legacy HomeKit data for %s", name) - hass.async_add_executor_job( - migrate_filesystem_state_data_for_primary_imported_entry_id, - hass, - entry.entry_id, - ) - aid_storage = AccessoryAidStorage(hass, entry.entry_id) await aid_storage.async_initialize() # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS) advertise_ip = conf.get(CONF_ADVERTISE_IP) + # exclude_accessory_mode is only used for config flow + # to indicate that the config entry was setup after + # we started creating config entries for entities that + # to run in accessory mode and that we should never include + # these entities on the bridge. For backwards compatibility + # with users who have not migrated yet we do not do exclude + # these entities by default as we cannot migrate automatically + # since it requires a re-pairing. + exclude_accessory_mode = conf.get( + CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE + ) homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) @@ -255,13 +269,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): port, ip_address, entity_filter, + exclude_accessory_mode, entity_config, homekit_mode, advertise_ip, entry.entry_id, + entry.title, ) zeroconf_instance = await zeroconf.async_get_instance(hass) - await hass.async_add_executor_job(homekit.setup, zeroconf_instance) + + # If the previous instance hasn't cleaned up yet + # we need to wait a bit + try: + await hass.async_add_executor_job(homekit.setup, zeroconf_instance) + except (OSError, AttributeError) as ex: + _LOGGER.warning( + "%s could not be setup because the local port %s is in use", name, port + ) + raise ConfigEntryNotReady from ex undo_listener = entry.add_update_listener(_async_update_listener) @@ -419,10 +444,12 @@ class HomeKit: port, ip_address, entity_filter, + exclude_accessory_mode, entity_config, homekit_mode, advertise_ip=None, entry_id=None, + entry_title=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -431,8 +458,10 @@ class HomeKit: self._ip_address = ip_address self._filter = entity_filter self._config = entity_config + self._exclude_accessory_mode = exclude_accessory_mode self._advertise_ip = advertise_ip self._entry_id = entry_id + self._entry_title = entry_title self._homekit_mode = homekit_mode self.status = STATUS_READY @@ -441,9 +470,6 @@ class HomeKit: def setup(self, zeroconf_instance): """Set up bridge and accessory driver.""" - # pylint: disable=import-outside-toplevel - from .accessories import HomeDriver - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) ip_addr = self._ip_address or get_local_ip() persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) @@ -452,6 +478,7 @@ class HomeKit: self.hass, self._entry_id, self._name, + self._entry_title, loop=self.hass.loop, address=ip_addr, port=self._port, @@ -464,8 +491,11 @@ class HomeKit: # as pyhap uses a random one until state is restored if os.path.exists(persist_file): self.driver.load() - else: - self.driver.persist() + self.driver.state.config_version += 1 + if self.driver.state.config_version > 65535: + self.driver.state.config_version = 1 + + self.driver.persist() def reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" @@ -513,6 +543,18 @@ class HomeKit: ) return + if state_needs_accessory_mode(state): + if self._exclude_accessory_mode: + return + _LOGGER.warning( + "The bridge %s has entity %s. For best performance, " + "and to prevent unexpected unavailability, create and " + "pair a separate HomeKit instance in accessory mode for " + "this entity.", + self._name, + state.entity_id, + ) + aid = self.hass.data[DOMAIN][self._entry_id][ AID_STORAGE ].get_or_allocate_aid_for_entity_id(state.entity_id) @@ -523,24 +565,6 @@ class HomeKit: try: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: - if acc.category == CATEGORY_CAMERA: - _LOGGER.warning( - "The bridge %s has camera %s. For best performance, " - "and to prevent unexpected unavailability, create and " - "pair a separate HomeKit instance in accessory mode for " - "each camera.", - self._name, - acc.entity_id, - ) - elif acc.category == CATEGORY_TELEVISION: - _LOGGER.warning( - "The bridge %s has tv %s. For best performance, " - "and to prevent unexpected unavailability, create and " - "pair a separate HomeKit instance in accessory mode for " - "each tv media player.", - self._name, - acc.entity_id, - ) self.bridge.add_accessory(acc) except Exception: # pylint: disable=broad-except _LOGGER.exception( @@ -575,20 +599,22 @@ class HomeKit: bridged_states = [] for state in self.hass.states.async_all(): - if not self._filter(state.entity_id): + entity_id = state.entity_id + + if not self._filter(entity_id): continue - ent_reg_ent = ent_reg.async_get(state.entity_id) + ent_reg_ent = ent_reg.async_get(entity_id) if ent_reg_ent: await self._async_set_device_info_attributes( - ent_reg_ent, dev_reg, state.entity_id + ent_reg_ent, dev_reg, entity_id ) self._async_configure_linked_sensors(ent_reg_ent, device_lookup, state) bridged_states.append(state) self._async_register_bridge(dev_reg) - await self.hass.async_add_executor_job(self._start, bridged_states) + await self._async_start(bridged_states) _LOGGER.debug("Driver start for %s", self._name) self.hass.add_job(self.driver.start_service) self.status = STATUS_RUNNING @@ -612,13 +638,15 @@ class HomeKit: connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) + is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY + hk_mode_name = "Accessory" if is_accessory_mode else "Bridge" dev_reg.async_get_or_create( config_entry_id=self._entry_id, identifiers={identifier}, connections={connection}, manufacturer=MANUFACTURER, name=self._name, - model="Home Assistant HomeKit Bridge", + model=f"Home Assistant HomeKit {hk_mode_name}", ) @callback @@ -635,40 +663,27 @@ class HomeKit: for device_id in devices_to_purge: dev_reg.async_remove_device(device_id) - def _start(self, bridged_states): - # pylint: disable=unused-import, import-outside-toplevel - from . import ( # noqa: F401 - type_cameras, - type_covers, - type_fans, - type_humidifiers, - type_lights, - type_locks, - type_media_players, - type_security_systems, - type_sensors, - type_switches, - type_thermostats, - ) - + async def _async_start(self, entity_states): + """Start the accessory.""" if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: - state = bridged_states[0] + state = entity_states[0] conf = self._config.pop(state.entity_id, {}) acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + self.driver.add_accessory(acc) else: - from .accessories import HomeBridge - self.bridge = HomeBridge(self.hass, self.driver, self._name) - for state in bridged_states: + for state in entity_states: self.add_bridge_accessory(state) - self.driver.add_accessory(self.bridge) + acc = self.bridge + + await self.hass.async_add_executor_job(self.driver.add_accessory, acc) if not self.driver.state.paired: show_setup_message( self.hass, self._entry_id, - self._name, + accessory_friendly_name(self._entry_title, self.driver.accessory), self.driver.state.pincode, self.driver.accessory.xhm_uri(), ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 51b6508149b..7e68daf4b62 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,7 +1,4 @@ """Extend the basic Accessory and Bridge functions.""" -from datetime import timedelta -from functools import partial, wraps -from inspect import getmodule import logging from pyhap.accessory import Accessory, Bridge @@ -37,11 +34,7 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Context, callback as ha_callback, split_entity_id -from homeassistant.helpers.event import ( - async_track_state_change_event, - track_point_in_utc_time, -) -from homeassistant.util import dt as dt_util +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.decorator import Registry from .const import ( @@ -60,7 +53,6 @@ from .const import ( CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, - DEBOUNCE_TIMEOUT, DEFAULT_LOW_BATTERY_THRESHOLD, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, @@ -79,6 +71,7 @@ from .const import ( TYPE_VALVE, ) from .util import ( + accessory_friendly_name, convert_to_float, dismiss_setup_message, format_sw_version, @@ -98,37 +91,6 @@ SWITCH_TYPES = { TYPES = Registry() -def debounce(func): - """Decorate function to debounce callbacks from HomeKit.""" - - @ha_callback - def call_later_listener(self, *args): - """Handle call_later callback.""" - debounce_params = self.debounce.pop(func.__name__, None) - if debounce_params: - self.hass.async_add_executor_job(func, self, *debounce_params[1:]) - - @wraps(func) - def wrapper(self, *args): - """Start async timer.""" - debounce_params = self.debounce.pop(func.__name__, None) - if debounce_params: - debounce_params[0]() # remove listener - remove_listener = track_point_in_utc_time( - self.hass, - partial(call_later_listener, self), - dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), - ) - self.debounce[func.__name__] = (remove_listener, *args) - logger.debug( - "%s: Start %s timeout", self.entity_id, func.__name__.replace("set_", "") - ) - - name = getmodule(func).__name__ - logger = logging.getLogger(name) - return wrapper - - def get_accessory(hass, driver, state, aid, config): """Take state and return an accessory object if supported.""" if not aid: @@ -278,7 +240,6 @@ class HomeAccessory(Accessory): self.category = category self.entity_id = entity_id self.hass = hass - self.debounce = {} self._subscriptions = [] self._char_battery = None self._char_charging = None @@ -340,17 +301,7 @@ class HomeAccessory(Accessory): return state is not None and state.state != STATE_UNAVAILABLE async def run(self): - """Handle accessory driver started event. - - Run inside the HAP-python event loop. - """ - self.hass.add_job(self.run_handler) - - async def run_handler(self): - """Handle accessory driver started event. - - Run inside the Home Assistant event loop. - """ + """Handle accessory driver started event.""" state = self.hass.states.get(self.entity_id) self.async_update_state_callback(state) self._subscriptions.append( @@ -481,15 +432,9 @@ class HomeAccessory(Accessory): """ raise NotImplementedError() - def call_service(self, domain, service, service_data, value=None): + @ha_callback + def async_call_service(self, domain, service, service_data, value=None): """Fire event and call service for changes from HomeKit.""" - self.hass.add_job(self.async_call_service, domain, service, service_data, value) - - async def async_call_service(self, domain, service, service_data, value=None): - """Fire event and call service for changes from HomeKit. - - This method must be run in the event loop. - """ event_data = { ATTR_ENTITY_ID: self.entity_id, ATTR_DISPLAY_NAME: self.display_name, @@ -499,8 +444,10 @@ class HomeAccessory(Accessory): context = Context() self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context) - await self.hass.services.async_call( - domain, service, service_data, context=context + self.hass.async_create_task( + self.hass.services.async_call( + domain, service, service_data, context=context + ) ) @ha_callback @@ -527,28 +474,29 @@ class HomeBridge(Bridge): def setup_message(self): """Prevent print of pyhap setup message to terminal.""" - def get_snapshot(self, info): + async def async_get_snapshot(self, info): """Get snapshot from accessory if supported.""" acc = self.accessories.get(info["aid"]) if acc is None: raise ValueError("Requested snapshot for missing accessory") - if not hasattr(acc, "get_snapshot"): + if not hasattr(acc, "async_get_snapshot"): raise ValueError( "Got a request for snapshot, but the Accessory " - 'does not define a "get_snapshot" method' + 'does not define a "async_get_snapshot" method' ) - return acc.get_snapshot(info) + return await acc.async_get_snapshot(info) class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" - def __init__(self, hass, entry_id, bridge_name, **kwargs): + def __init__(self, hass, entry_id, bridge_name, entry_title, **kwargs): """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass self._entry_id = entry_id self._bridge_name = bridge_name + self._entry_title = entry_title def pair(self, client_uuid, client_public): """Override super function to dismiss setup message if paired.""" @@ -560,10 +508,14 @@ class HomeDriver(AccessoryDriver): def unpair(self, client_uuid): """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) + + if self.state.paired: + return + show_setup_message( self.hass, self._entry_id, - self._bridge_name, + accessory_friendly_name(self._entry_title, self.accessory), self.state.pincode, self.accessory.xhm_uri(), ) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 8d763581615..c21c27fba83 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,12 +1,22 @@ """Config flow for HomeKit integration.""" import random +import re import string import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_NAME, CONF_PORT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PORT, +) from homeassistant.core import callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( @@ -19,6 +29,7 @@ from homeassistant.helpers.entityfilter import ( from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, + CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, CONF_VIDEO_CODEC, @@ -26,12 +37,13 @@ from .const import ( DEFAULT_CONFIG_FLOW_PORT, DEFAULT_HOMEKIT_MODE, HOMEKIT_MODE_ACCESSORY, + HOMEKIT_MODE_BRIDGE, HOMEKIT_MODES, SHORT_BRIDGE_NAME, VIDEO_CODEC_COPY, ) from .const import DOMAIN # pylint:disable=unused-import -from .util import find_next_available_port +from .util import async_find_next_available_port, state_needs_accessory_mode CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" @@ -41,11 +53,16 @@ MODE_EXCLUDE = "exclude" INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] +DOMAINS_NEED_ACCESSORY_MODE = [CAMERA_DOMAIN, MEDIA_PLAYER_DOMAIN] +NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] + +CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." + SUPPORTED_DOMAINS = [ "alarm_control_panel", "automation", "binary_sensor", - "camera", + CAMERA_DOMAIN, "climate", "cover", "demo", @@ -55,7 +72,7 @@ SUPPORTED_DOMAINS = [ "input_boolean", "light", "lock", - "media_player", + MEDIA_PLAYER_DOMAIN, "person", "remote", "scene", @@ -69,17 +86,25 @@ SUPPORTED_DOMAINS = [ DEFAULT_DOMAINS = [ "alarm_control_panel", "climate", + CAMERA_DOMAIN, "cover", "humidifier", "fan", "light", "lock", - "media_player", + MEDIA_PLAYER_DOMAIN, "switch", "vacuum", "water_heater", ] +_EMPTY_ENTITY_FILTER = { + CONF_INCLUDE_DOMAINS: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_ENTITIES: [], +} + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HomeKit.""" @@ -89,51 +114,98 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize config flow.""" - self.homekit_data = {} - self.entry_title = None + self.hk_data = {} + + async def async_step_user(self, user_input=None): + """Choose specific domains in bridge mode.""" + if user_input is not None: + entity_filter = _EMPTY_ENTITY_FILTER.copy() + entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] + self.hk_data[CONF_FILTER] = entity_filter + return await self.async_step_pairing() + + self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE + default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_INCLUDE_DOMAINS, default=default_domains + ): cv.multi_select(SUPPORTED_DOMAINS), + } + ), + ) async def async_step_pairing(self, user_input=None): """Pairing instructions.""" if user_input is not None: - return self.async_create_entry( - title=self.entry_title, data=self.homekit_data + port = await async_find_next_available_port( + self.hass, DEFAULT_CONFIG_FLOW_PORT ) + await self._async_add_entries_for_accessory_mode_entities(port) + self.hk_data[CONF_PORT] = port + include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] + for domain in NEVER_BRIDGED_DOMAINS: + if domain in include_domains_filter: + include_domains_filter.remove(domain) + return self.async_create_entry( + title=f"{self.hk_data[CONF_NAME]}:{self.hk_data[CONF_PORT]}", + data=self.hk_data, + ) + + self.hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) + self.hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True return self.async_show_form( step_id="pairing", - description_placeholders={CONF_NAME: self.homekit_data[CONF_NAME]}, + description_placeholders={CONF_NAME: self.hk_data[CONF_NAME]}, ) - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} - if user_input is not None: - port = await self._async_available_port() - name = self._async_available_name() - title = f"{name}:{port}" - self.homekit_data = user_input.copy() - self.homekit_data[CONF_NAME] = name - self.homekit_data[CONF_PORT] = port - self.homekit_data[CONF_FILTER] = { - CONF_INCLUDE_DOMAINS: user_input[CONF_INCLUDE_DOMAINS], - CONF_INCLUDE_ENTITIES: [], - CONF_EXCLUDE_DOMAINS: [], - CONF_EXCLUDE_ENTITIES: [], - } - del self.homekit_data[CONF_INCLUDE_DOMAINS] - self.entry_title = title - return await self.async_step_pairing() - - default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS - setup_schema = vol.Schema( - { - vol.Required( - CONF_INCLUDE_DOMAINS, default=default_domains - ): cv.multi_select(SUPPORTED_DOMAINS), - } + async def _async_add_entries_for_accessory_mode_entities(self, last_assigned_port): + """Generate new flows for entities that need their own instances.""" + accessory_mode_entity_ids = _async_get_entity_ids_for_accessory_mode( + self.hass, self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] ) + exiting_entity_ids_accessory_mode = _async_entity_ids_with_accessory_mode( + self.hass + ) + next_port_to_check = last_assigned_port + 1 + for entity_id in accessory_mode_entity_ids: + if entity_id in exiting_entity_ids_accessory_mode: + continue + port = await async_find_next_available_port(self.hass, next_port_to_check) + next_port_to_check = port + 1 + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "accessory"}, + data={CONF_ENTITY_ID: entity_id, CONF_PORT: port}, + ) + ) - return self.async_show_form( - step_id="user", data_schema=setup_schema, errors=errors + async def async_step_accessory(self, accessory_input): + """Handle creation a single accessory in accessory mode.""" + entity_id = accessory_input[CONF_ENTITY_ID] + port = accessory_input[CONF_PORT] + + state = self.hass.states.get(entity_id) + name = state.attributes.get(ATTR_FRIENDLY_NAME) or state.entity_id + entity_filter = _EMPTY_ENTITY_FILTER.copy() + entity_filter[CONF_INCLUDE_ENTITIES] = [entity_id] + + entry_data = { + CONF_PORT: port, + CONF_NAME: self._async_available_name(name), + CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, + CONF_FILTER: entity_filter, + } + if entity_id.startswith(CAMERA_ENTITY_PREFIX): + entry_data[CONF_ENTITY_CONFIG] = { + entity_id: {CONF_VIDEO_CODEC: VIDEO_CODEC_COPY} + } + + return self.async_create_entry( + title=f"{name}:{entry_data[CONF_PORT]}", data=entry_data ) async def async_step_import(self, user_input=None): @@ -144,37 +216,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=f"{user_input[CONF_NAME]}:{user_input[CONF_PORT]}", data=user_input ) - async def _async_available_port(self): - """Return an available port the bridge.""" - return await self.hass.async_add_executor_job( - find_next_available_port, DEFAULT_CONFIG_FLOW_PORT - ) - @callback def _async_current_names(self): """Return a set of bridge names.""" - current_entries = self._async_current_entries() - return { entry.data[CONF_NAME] - for entry in current_entries + for entry in self._async_current_entries() if CONF_NAME in entry.data } @callback - def _async_available_name(self): + def _async_available_name(self, requested_name): """Return an available for the bridge.""" + current_names = self._async_current_names() + valid_mdns_name = re.sub("[^A-Za-z0-9 ]+", " ", requested_name) - # We always pick a RANDOM name to avoid Zeroconf - # name collisions. If the name has been seen before - # pairing will probably fail. - acceptable_chars = string.ascii_uppercase + string.digits - trailer = "".join(random.choices(acceptable_chars, k=4)) - all_names = self._async_current_names() - suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" - while suggested_name in all_names: - trailer = "".join(random.choices(acceptable_chars, k=4)) - suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" + if valid_mdns_name not in current_names: + return valid_mdns_name + + acceptable_mdns_chars = string.ascii_uppercase + string.digits + suggested_name = None + while not suggested_name or suggested_name in current_names: + trailer = "".join(random.choices(acceptable_mdns_chars, k=2)) + suggested_name = f"{valid_mdns_name} {trailer}" return suggested_name @@ -196,12 +260,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for tado.""" + """Handle a option flow for homekit.""" def __init__(self, config_entry: config_entries.ConfigEntry): """Initialize options flow.""" self.config_entry = config_entry - self.homekit_options = {} + self.hk_options = {} self.included_cameras = set() async def async_step_yaml(self, user_input=None): @@ -217,17 +281,17 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Choose advanced options.""" if not self.show_advanced_options or user_input is not None: if user_input: - self.homekit_options.update(user_input) + self.hk_options.update(user_input) - self.homekit_options[CONF_AUTO_START] = self.homekit_options.get( + self.hk_options[CONF_AUTO_START] = self.hk_options.get( CONF_AUTO_START, DEFAULT_AUTO_START ) for key in (CONF_DOMAINS, CONF_ENTITIES): - if key in self.homekit_options: - del self.homekit_options[key] + if key in self.hk_options: + del self.hk_options[key] - return self.async_create_entry(title="", data=self.homekit_options) + return self.async_create_entry(title="", data=self.hk_options) return self.async_show_form( step_id="advanced", @@ -235,7 +299,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): { vol.Optional( CONF_AUTO_START, - default=self.homekit_options.get( + default=self.hk_options.get( CONF_AUTO_START, DEFAULT_AUTO_START ), ): bool @@ -246,7 +310,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_cameras(self, user_input=None): """Choose camera config.""" if user_input is not None: - entity_config = self.homekit_options[CONF_ENTITY_CONFIG] + entity_config = self.hk_options[CONF_ENTITY_CONFIG] for entity_id in self.included_cameras: if entity_id in user_input[CONF_CAMERA_COPY]: entity_config.setdefault(entity_id, {})[ @@ -260,7 +324,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_advanced() cameras_with_copy = [] - entity_config = self.homekit_options.setdefault(CONF_ENTITY_CONFIG, {}) + entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: hk_entity_config = entity_config.get(entity, {}) if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: @@ -279,19 +343,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_include_exclude(self, user_input=None): """Choose entities to include or exclude from the domain.""" if user_input is not None: - entity_filter = { - CONF_INCLUDE_DOMAINS: [], - CONF_EXCLUDE_DOMAINS: [], - CONF_INCLUDE_ENTITIES: [], - CONF_EXCLUDE_ENTITIES: [], - } + entity_filter = _EMPTY_ENTITY_FILTER.copy() if isinstance(user_input[CONF_ENTITIES], list): entities = user_input[CONF_ENTITIES] else: entities = [user_input[CONF_ENTITIES]] if ( - self.homekit_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY + self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY or user_input[CONF_INCLUDE_EXCLUDE_MODE] == MODE_INCLUDE ): entity_filter[CONF_INCLUDE_ENTITIES] = entities @@ -300,42 +359,47 @@ class OptionsFlowHandler(config_entries.OptionsFlow): domains_with_entities_selected = _domains_set_from_entities(entities) entity_filter[CONF_INCLUDE_DOMAINS] = [ domain - for domain in self.homekit_options[CONF_DOMAINS] + for domain in self.hk_options[CONF_DOMAINS] if domain not in domains_with_entities_selected ] - for entity_id in list(self.included_cameras): - if entity_id not in entities: - self.included_cameras.remove(entity_id) + self.included_cameras = { + entity_id + for entity_id in entities + if entity_id.startswith(CAMERA_ENTITY_PREFIX) + } else: - entity_filter[CONF_INCLUDE_DOMAINS] = self.homekit_options[CONF_DOMAINS] + entity_filter[CONF_INCLUDE_DOMAINS] = self.hk_options[CONF_DOMAINS] entity_filter[CONF_EXCLUDE_ENTITIES] = entities - for entity_id in entities: - if entity_id in self.included_cameras: - self.included_cameras.remove(entity_id) + if CAMERA_DOMAIN in entity_filter[CONF_INCLUDE_DOMAINS]: + camera_entities = _async_get_matching_entities( + self.hass, + domains=[CAMERA_DOMAIN], + ) + self.included_cameras = { + entity_id + for entity_id in camera_entities + if entity_id not in entities + } + else: + self.included_cameras = set() - self.homekit_options[CONF_FILTER] = entity_filter + self.hk_options[CONF_FILTER] = entity_filter if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() - entity_filter = self.homekit_options.get(CONF_FILTER, {}) - all_supported_entities = await self.hass.async_add_executor_job( - _get_entities_matching_domains, + entity_filter = self.hk_options.get(CONF_FILTER, {}) + all_supported_entities = _async_get_matching_entities( self.hass, - self.homekit_options[CONF_DOMAINS], + domains=self.hk_options[CONF_DOMAINS], ) - self.included_cameras = { - entity_id - for entity_id in all_supported_entities - if entity_id.startswith("camera.") - } data_schema = {} entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - if self.homekit_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: + if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: entity_schema = vol.In else: if entities: @@ -362,47 +426,81 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_yaml(user_input) if user_input is not None: - self.homekit_options.update(user_input) + self.hk_options.update(user_input) return await self.async_step_include_exclude() - self.homekit_options = dict(self.config_entry.options) - entity_filter = self.homekit_options.get(CONF_FILTER, {}) - - homekit_mode = self.homekit_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) + self.hk_options = dict(self.config_entry.options) + entity_filter = self.hk_options.get(CONF_FILTER, {}) + homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) if include_entities: domains.extend(_domains_set_from_entities(include_entities)) - data_schema = vol.Schema( - { - vol.Optional(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( - HOMEKIT_MODES - ), - vol.Optional( - CONF_DOMAINS, - default=domains, - ): cv.multi_select(SUPPORTED_DOMAINS), - } + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( + HOMEKIT_MODES + ), + vol.Required( + CONF_DOMAINS, + default=domains, + ): cv.multi_select(SUPPORTED_DOMAINS), + } + ), ) - return self.async_show_form(step_id="init", data_schema=data_schema) -def _get_entities_matching_domains(hass, domains): - """List entities in the given domains.""" - included_domains = set(domains) - entity_ids = [ - state.entity_id - for state in hass.states.all() - if (split_entity_id(state.entity_id))[0] in included_domains - ] - entity_ids.sort() - return entity_ids +def _async_get_matching_entities(hass, domains=None): + """Fetch all entities or entities in the given domains.""" + return { + state.entity_id: f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" + for state in sorted( + hass.states.async_all(domains and set(domains)), + key=lambda item: item.entity_id, + ) + } def _domains_set_from_entities(entity_ids): """Build a set of domains for the given entity ids.""" - domains = set() - for entity_id in entity_ids: - domains.add(split_entity_id(entity_id)[0]) - return domains + return {split_entity_id(entity_id)[0] for entity_id in entity_ids} + + +@callback +def _async_get_entity_ids_for_accessory_mode(hass, include_domains): + """Build a list of entities that should be paired in accessory mode.""" + accessory_mode_domains = { + domain for domain in include_domains if domain in DOMAINS_NEED_ACCESSORY_MODE + } + + if not accessory_mode_domains: + return [] + + return [ + state.entity_id + for state in hass.states.async_all(accessory_mode_domains) + if state_needs_accessory_mode(state) + ] + + +@callback +def _async_entity_ids_with_accessory_mode(hass): + """Return a set of entity ids that have config entries in accessory mode.""" + + entity_ids = set() + + current_entries = hass.config_entries.async_entries(DOMAIN) + for entry in current_entries: + # We have to handle the case where the data has not yet + # been migrated to options because the data was just + # imported and the entry was never started + target = entry.options if CONF_HOMEKIT_MODE in entry.options else entry.data + if target.get(CONF_HOMEKIT_MODE) != HOMEKIT_MODE_ACCESSORY: + continue + + entity_ids.add(target[CONF_FILTER][CONF_INCLUDE_ENTITIES][0]) + + return entity_ids diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 77c5dbff0f9..67312903b50 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -42,6 +42,7 @@ CONF_ENTITY_CONFIG = "entity_config" CONF_FEATURE = "feature" CONF_FEATURE_LIST = "feature_list" CONF_FILTER = "filter" +CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" @@ -68,6 +69,7 @@ DEFAULT_AUDIO_CODEC = AUDIO_CODEC_OPUS DEFAULT_AUDIO_MAP = "0:a:0" DEFAULT_AUDIO_PACKET_SIZE = 188 DEFAULT_AUTO_START = True +DEFAULT_EXCLUDE_ACCESSORY_MODE = False DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_MAX_FPS = 30 DEFAULT_MAX_HEIGHT = 1080 @@ -104,6 +106,7 @@ SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" BRIDGE_MODEL = "Bridge" BRIDGE_NAME = "Home Assistant Bridge" SHORT_BRIDGE_NAME = "HASS Bridge" +SHORT_ACCESSORY_NAME = "HASS Accessory" BRIDGE_SERIAL_NUMBER = "homekit.bridge" MANUFACTURER = "Home Assistant" diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index d188dd270ab..ac3fb0251e2 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.1.0", + "HAP-python==3.3.2", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index c2dde2cac6c..6f9c005ed64 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -1,14 +1,11 @@ # Describes the format for available HomeKit services start: - description: Starts the HomeKit driver. + description: Starts the HomeKit driver reload: - description: Reload homekit and re-process yaml configuration. + description: Reload homekit and re-process YAML configuration reset_accessory: - description: Reset a HomeKit accessory. This can be useful when changing a media_player’s device class to tv, linking a battery, or whenever Home Assistant adds support for new HomeKit features to existing entities. - fields: - entity_id: - description: Name of the entity to reset. - example: "binary_sensor.grid_status" + description: Reset a HomeKit accessory + target: diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 5ba578f38c3..a9b7c1c6cc1 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -10,16 +10,16 @@ "mode": "[%key:common::config_flow::data::mode%]", "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be exposed to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", - "title": "Select domains to expose." + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "title": "Select domains to be included." }, "include_exclude": { "data": { "mode": "[%key:common::config_flow::data::mode%]", "entities": "Entities" }, - "description": "Choose the entities to be exposed. In accessory mode, only a single entity is exposed. In bridge include mode, all entities in the domain will be exposed unless specific entities are selected. In bridge exclude mode, all entities in the domain will be exposed except for the excluded entities. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Select entities to be exposed" + "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, a seperate HomeKit accessory will beeach tv media player and camera.", + "title": "Select entities to be included" }, "cameras": { "data": { @@ -43,12 +43,12 @@ "data": { "include_domains": "Domains to include" }, - "description": "The HomeKit integration will allow you to access your Home Assistant entities in HomeKit. In bridge mode, HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Activate HomeKit" + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "title": "Select domains to be included" }, "pairing": { "title": "Pair HomeKit", - "description": "As soon as the {name} is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d." + "description": "To complete pairing following the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d." } }, "abort": { diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index 0870b05a6d1..dbd83622d8a 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -19,7 +19,7 @@ "title": "Selecciona els dominis a incloure" }, "pairing": { - "description": "Tan aviat com {name} estigui llest, la vinculaci\u00f3 estar\u00e0 disponible a \"Notificacions\" com a \"Configuraci\u00f3 de l'enlla\u00e7 HomeKit\".", + "description": "Per completar la vinculaci\u00f3, segueix les instruccions a \"Configuraci\u00f3 de l'enlla\u00e7 HomeKit\" sota \"Notificacions\".", "title": "Vinculaci\u00f3 HomeKit" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "Dominis a incloure", "mode": "Mode" }, - "description": "La integraci\u00f3 HomeKit et permetr\u00e0 l'acc\u00e9s a les teves entitats de Home Assistant a HomeKit. En mode enlla\u00e7, els enlla\u00e7os HomeKit estan limitats a un m\u00e0xim de 150 accessoris per inst\u00e0ncia (incl\u00f2s el propi enlla\u00e7). Si volguessis enlla\u00e7ar m\u00e9s accessoris que el m\u00e0xim perm\u00e8s, \u00e9s recomanable que utilitzis diferents enlla\u00e7os HomeKit per a dominis diferents. La configuraci\u00f3 avan\u00e7ada d'entitat nom\u00e9s est\u00e0 disponible en YAML. Per obtenir el millor rendiment i evitar errors de disponibilitat inesperats , crea i vincula una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", - "title": "Activaci\u00f3 de HomeKit" + "description": "Selecciona els dominis a incloure. S'inclouran totes les entitats del domini compatibles. Es crear\u00e0 una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", + "title": "Selecciona els dominis a incloure" } } }, @@ -55,7 +55,7 @@ "entities": "Entitats", "mode": "Mode" }, - "description": "Tria les entitats que vulguis incloure. En mode accessori, nom\u00e9s s'inclou una sola entitat. En mode enlla\u00e7 inclusiu, s'exposaran totes les entitats del domini tret de que se'n seleccionin algunes en concret. En mode enlla\u00e7 excusiu, s'inclouran totes les entitats del domini excepte les entitats excloses. Per obtenir el millor rendiment i evitar errors de disponibilitat inesperats , crea i vincula una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", + "description": "Tria les entitats que vulguis incloure. En mode accessori, nom\u00e9s s'inclou una sola entitat. En mode enlla\u00e7 inclusiu, s'exposaran totes les entitats del domini tret de que se'n seleccionin algunes en concret. En mode enlla\u00e7 excusiu, s'inclouran totes les entitats del domini excepte les entitats excloses. Per obtenir el millor rendiment, es crea una inst\u00e0ncia HomeKit per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", "title": "Selecciona les entitats a incloure" }, "init": { diff --git a/homeassistant/components/homekit/translations/cs.json b/homeassistant/components/homekit/translations/cs.json index faf1b1d74fc..cdfaed9183c 100644 --- a/homeassistant/components/homekit/translations/cs.json +++ b/homeassistant/components/homekit/translations/cs.json @@ -17,7 +17,7 @@ "include_domains": "Dom\u00e9ny, kter\u00e9 maj\u00ed b\u00fdt zahrnuty", "mode": "Re\u017eim" }, - "title": "Aktivace HomeKit" + "title": "Vyberte dom\u00e9ny, kter\u00e9 chcete zahrnout" } } }, diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index 6d69c498bac..88583d9ca80 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -4,12 +4,27 @@ "port_name_in_use": "Eine HomeKit Bridge mit demselben Namen oder Port ist bereits vorhanden." }, "step": { + "accessory_mode": { + "data": { + "entity_id": "Entit\u00e4t" + }, + "description": "W\u00e4hle die Entit\u00e4t aus, die aufgenommen werden soll. Im Zubeh\u00f6rmodus ist nur eine einzelne Entit\u00e4t enthalten.", + "title": "W\u00e4hle die Entit\u00e4t aus, die aufgenommen werden soll" + }, + "bridge_mode": { + "data": { + "include_domains": "Einzubeziehende Domains" + }, + "description": "W\u00e4hle die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Ger\u00e4te innerhalb der Domain werden aufgenommen.", + "title": "W\u00e4hle die Domains aus, die aufgenommen werden sollen" + }, "pairing": { "title": "HomeKit verbinden" }, "user": { "data": { - "include_domains": "Einzubeziehende Domains" + "include_domains": "Einzubeziehende Domains", + "mode": "Modus" }, "title": "HomeKit aktivieren" } @@ -21,6 +36,7 @@ "data": { "safe_mode": "Abgesicherter Modus (nur aktivieren, wenn das Pairing fehlschl\u00e4gt)" }, + "description": "Diese Einstellungen m\u00fcssen nur angepasst werden, wenn HomeKit nicht funktioniert.", "title": "Erweiterte Konfiguration" }, "cameras": { @@ -33,7 +49,8 @@ "data": { "entities": "Entit\u00e4ten", "mode": "Modus" - } + }, + "title": "W\u00e4hle die Entit\u00e4ten aus, die aufgenommen werden sollen" }, "init": { "data": { diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index c5ffa2e9aa4..3b0129567c4 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -1,17 +1,26 @@ { - "options": { + "config": { + "abort": { + "port_name_in_use": "An accessory or bridge with the same name or port is already configured." + }, "step": { - "yaml": { - "title": "Adjust HomeKit Options", - "description": "This entry is controlled via YAML" - }, - "init": { + "accessory_mode": { "data": { - "mode": "[%key:common::config_flow::data::mode%]", - "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" + "entity_id": "Entity" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be exposed to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", - "title": "Select domains to expose." + "description": "Choose the entity to be included. In accessory mode, only a single entity is included.", + "title": "Select entity to be included" + }, + "bridge_mode": { + "data": { + "include_domains": "Domains to include" + }, + "description": "Choose the domains to be included. All supported entities in the domain will be included.", + "title": "Select domains to be included" + }, + "pairing": { + "description": "To complete pairing following the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", + "title": "Pair HomeKit" }, "user": { "data": { @@ -19,8 +28,8 @@ "include_domains": "Domains to include", "mode": "Mode" }, - "description": "The HomeKit integration will allow you to access your Home Assistant entities in HomeKit. In bridge mode, HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Activate HomeKit" + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "title": "Select domains to be included" } } }, @@ -31,8 +40,8 @@ "auto_start": "Autostart (disable if you are calling the homekit.start service manually)", "safe_mode": "Safe Mode (enable only if pairing fails)" }, - "description": "Choose the entities to be exposed. In accessory mode, only a single entity is exposed. In bridge include mode, all entities in the domain will be exposed unless specific entities are selected. In bridge exclude mode, all entities in the domain will be exposed except for the excluded entities. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Select entities to be exposed" + "description": "These settings only need to be adjusted if HomeKit is not functional.", + "title": "Advanced Configuration" }, "cameras": { "data": { @@ -41,31 +50,26 @@ "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", "title": "Select camera video codec." }, - "advanced": { + "include_exclude": { "data": { - "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" + "entities": "Entities", + "mode": "Mode" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", - "title": "Advanced Configuration" - } - } - }, - "config": { - "step": { - "user": { - "data": { - "include_domains": "Domains to include" - }, - "description": "The HomeKit integration will allow you to access your Home Assistant entities in HomeKit. In bridge mode, HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Activate HomeKit" + "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, a seperate HomeKit accessory will beeach tv media player and camera.", + "title": "Select entities to be included" }, - "pairing": { - "title": "Pair HomeKit", - "description": "As soon as the {name} is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d." + "init": { + "data": { + "include_domains": "Domains to include", + "mode": "Mode" + }, + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "title": "Select domains to be included." + }, + "yaml": { + "description": "This entry is controlled via YAML", + "title": "Adjust HomeKit Options" } - }, - "abort": { - "port_name_in_use": "An accessory or bridge with the same name or port is already configured." } } -} +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index aeb75f838c1..694b7dcdb6c 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -4,6 +4,20 @@ "port_name_in_use": "Ya est\u00e1 configurada una pasarela con el mismo nombre o puerto." }, "step": { + "accessory_mode": { + "data": { + "entity_id": "Entidad" + }, + "description": "Elija la entidad que desea incluir. En el modo accesorio, s\u00f3lo se incluye una \u00fanica entidad.", + "title": "Seleccione la entidad a incluir" + }, + "bridge_mode": { + "data": { + "include_domains": "Dominios a incluir" + }, + "description": "Elija los dominios que se van a incluir. Se incluir\u00e1n todas las entidades admitidas en el dominio.", + "title": "Selecciona los dominios a incluir" + }, "pairing": { "description": "Tan pronto como la pasarela {name} est\u00e9 lista, la vinculaci\u00f3n estar\u00e1 disponible en \"Notificaciones\" como \"configuraci\u00f3n de pasarela Homekit\"", "title": "Vincular pasarela Homekit" @@ -11,7 +25,8 @@ "user": { "data": { "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)", - "include_domains": "Dominios para incluir" + "include_domains": "Dominios para incluir", + "mode": "Modo" }, "description": "Una pasarela Homekit permitir\u00e1 a Homekit acceder a sus entidades de Home Assistant. La pasarela Homekit est\u00e1 limitada a 150 accesorios por instancia incluyendo la propia pasarela. Si desea enlazar m\u00e1s del m\u00e1ximo n\u00famero de accesorios, se recomienda que use multiples pasarelas Homekit para diferentes dominios. Configuraci\u00f3n detallada de la entidad solo est\u00e1 disponible via YAML para la pasarela primaria.", "title": "Activar pasarela Homekit" diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 37bff5f9b70..8c24d2d2251 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -19,7 +19,7 @@ "title": "Vali kaasatavad domeenid" }, "pairing": { - "description": "Niipea kui {name} on valmis, on sidumine saadaval jaotises \"Notifications\" kui \"HomeKit Bridge Setup\".", + "description": "Sidumise l\u00f5puleviimiseks j\u00e4rgi jaotises \"HomeKiti sidumine\" toodud juhiseid alajaotises \"Teatised\".", "title": "HomeKiti sidumine" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "Kaasatavad domeenid", "mode": "Re\u017eiim" }, - "description": "HomeKiti integreerimine v\u00f5imaldab teil p\u00e4\u00e4seda juurde HomeKiti \u00fcksustele Home Assistant. Sildire\u017eiimis on HomeKit Bridges piiratud 150 lisaseadmega, sealhulgas sild ise. Kui soovid \u00fchendada rohkem lisatarvikuid, on soovitatav kasutada erinevate domeenide jaoks mitut HomeKiti silda. \u00dcksuse \u00fcksikasjalik konfiguratsioon on esmase silla jaoks saadaval ainult YAML-i kaudu. Parema tulemuse saavutamiseks ja ootamatute seadmete kadumise v\u00e4ltimiseks loo ja seo eraldi HomeKiti seade tarviku re\u017eiimis kga meediaesitaja ja kaamera jaoks.", - "title": "Aktiveeri HomeKit" + "description": "Vali kaasatavad domeenid. Kaasatakse k\u00f5ik domeenis toetatud olemid. Iga telemeedia pleieri ja kaamera jaoks luuakse eraldi HomeKiti eksemplar tarvikure\u017eiimis.", + "title": "Vali kaasatavad domeenid" } } }, @@ -55,12 +55,12 @@ "entities": "Olemid", "mode": "Re\u017eiim" }, - "description": "Vali kaasatavad olemid. Tarvikute re\u017eiimis on kaasatav ainult \u00fcks olem. Silla re\u017eiimis, kuvatakse k\u00f5ik domeeni olemid, v\u00e4lja arvatud juhul, kui valitud on kindlad olemid. Silla v\u00e4listamisre\u017eiimis kaasatakse k\u00f5ik domeeni olemid, v\u00e4lja arvatud v\u00e4listatud olemid.", + "description": "Vali kaasatavad olemid. Tarvikute re\u017eiimis on kaasatav ainult \u00fcks olem. Silla re\u017eiimis, kuvatakse k\u00f5ik domeeni olemid, v\u00e4lja arvatud juhul, kui valitud on kindlad olemid. Silla v\u00e4listamisre\u017eiimis kaasatakse k\u00f5ik domeeni olemid, v\u00e4lja arvatud v\u00e4listatud olemid. Parima kasutuskogemuse jaoks on eraldi HomeKit seadmed iga meediumim\u00e4ngija ja kaamera jaoks.", "title": "Vali kaasatavd olemid" }, "init": { "data": { - "include_domains": "Kaasatavad domeenid", + "include_domains": "Kaasatud domeenid", "mode": "Re\u017eiim" }, "description": "HomeKiti saab seadistada silla v\u00f5i \u00fche lisaseadme avaldamiseks. Lisare\u017eiimis saab kasutada ainult \u00fchte \u00fcksust. Teleriseadmete klassiga meediumipleierite n\u00f5uetekohaseks toimimiseks on vaja lisare\u017eiimi. \u201eKaasatavate domeenide\u201d \u00fcksused puutuvad kokku HomeKitiga. J\u00e4rgmisel ekraanil saad valida, millised \u00fcksused sellesse loendisse lisada v\u00f5i sellest v\u00e4lja j\u00e4tta.", diff --git a/homeassistant/components/homekit/translations/fr.json b/homeassistant/components/homekit/translations/fr.json index be7d30c30ee..4721514e615 100644 --- a/homeassistant/components/homekit/translations/fr.json +++ b/homeassistant/components/homekit/translations/fr.json @@ -4,17 +4,32 @@ "port_name_in_use": "Une passerelle avec le m\u00eame nom ou port est d\u00e9j\u00e0 configur\u00e9e." }, "step": { + "accessory_mode": { + "data": { + "entity_id": "Entit\u00e9" + }, + "description": "Choisissez l'entit\u00e9 \u00e0 inclure. En mode accessoire, une seule entit\u00e9 est incluse.", + "title": "S\u00e9lectionnez l'entit\u00e9 \u00e0 inclure" + }, + "bridge_mode": { + "data": { + "include_domains": "Domaines \u00e0 inclure" + }, + "description": "Choisissez les domaines \u00e0 inclure. Toutes les entit\u00e9s prises en charge dans le domaine seront incluses.", + "title": "S\u00e9lectionnez les domaines \u00e0 inclure" + }, "pairing": { - "description": "D\u00e8s que le pont {name} est pr\u00eat, l'appairage sera disponible dans \"Notifications\" sous \"Configuration de la Passerelle HomeKit\".", + "description": "Pour compl\u00e9ter l'appariement, suivez les instructions dans les \"Notifications\" sous \"Appariement HomeKit\".", "title": "Appairage de la Passerelle Homekit" }, "user": { "data": { "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", - "include_domains": "Domaines \u00e0 inclure" + "include_domains": "Domaines \u00e0 inclure", + "mode": "Mode" }, - "description": "La passerelle HomeKit vous permettra d'acc\u00e9der \u00e0 vos entit\u00e9s Home Assistant dans HomeKit. Les passerelles HomeKit sont limit\u00e9es \u00e0 150 accessoires par instance, y compris la passerelle elle-m\u00eame. Si vous souhaitez connecter plus que le nombre maximum d'accessoires, il est recommand\u00e9 d'utiliser plusieurs passerelles HomeKit pour diff\u00e9rents domaines. La configuration d\u00e9taill\u00e9e des entit\u00e9s est uniquement disponible via YAML pour la passerelle principale.", - "title": "Activer la Passerelle HomeKit" + "description": "Choisissez les domaines \u00e0 inclure. Toutes les entit\u00e9s prises en charge dans le domaine seront incluses. Une instance HomeKit distincte en mode accessoire sera cr\u00e9\u00e9e pour chaque lecteur multim\u00e9dia TV et cam\u00e9ra.", + "title": "S\u00e9lectionnez les domaines \u00e0 inclure" } } }, @@ -45,7 +60,7 @@ }, "init": { "data": { - "include_domains": "Domaine \u00e0 inclure", + "include_domains": "Domaines \u00e0 inclure", "mode": "Mode" }, "description": "Les entit\u00e9s des \u00abdomaines \u00e0 inclure\u00bb seront pont\u00e9es vers HomeKit. Vous pourrez s\u00e9lectionner les entit\u00e9s \u00e0 exclure de cette liste sur l'\u00e9cran suivant.", diff --git a/homeassistant/components/homekit/translations/he.json b/homeassistant/components/homekit/translations/he.json index 87ad743dca5..6acebca0ca4 100644 --- a/homeassistant/components/homekit/translations/he.json +++ b/homeassistant/components/homekit/translations/he.json @@ -1,6 +1,16 @@ { "options": { "step": { + "include_exclude": { + "data": { + "mode": "\u05de\u05e6\u05d1" + } + }, + "init": { + "data": { + "mode": "\u05de\u05e6\u05d1" + } + }, "yaml": { "description": "\u05d9\u05e9\u05d5\u05ea \u05d6\u05d5 \u05e0\u05e9\u05dc\u05d8\u05ea \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea YAML" } diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index 9a85d1e6e9f..fee64457652 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -19,7 +19,7 @@ "title": "Seleziona i domini da includere" }, "pairing": { - "description": "Non appena il {name} \u00e8 pronto, l'associazione sar\u00e0 disponibile in \"Notifiche\" come \"Configurazione HomeKit Bridge\".", + "description": "Per completare l'associazione, seguire le istruzioni in \"Notifiche\" sotto \"Associazione HomeKit\".", "title": "Associa HomeKit" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "Domini da includere", "mode": "Modalit\u00e0" }, - "description": "L'integrazione di HomeKit ti consentir\u00e0 di accedere alle entit\u00e0 di Home Assistant in HomeKit. In modalit\u00e0 bridge, i bridge HomeKit sono limitati a 150 accessori per istanza, incluso il bridge stesso. Se desideri eseguire il bridge di un numero di accessori superiore a quello massimo, si consiglia di utilizzare pi\u00f9 bridge HomeKit per domini diversi. La configurazione dettagliata dell'entit\u00e0 \u00e8 disponibile solo tramite YAML per il bridge principale.", - "title": "Attiva HomeKit" + "description": "Scegli i domini da includere. Verranno incluse tutte le entit\u00e0 supportate nel dominio. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale TV e telecamera.", + "title": "Seleziona i domini da includere" } } }, @@ -55,7 +55,7 @@ "entities": "Entit\u00e0", "mode": "Modalit\u00e0" }, - "description": "Scegliere le entit\u00e0 da includere. In modalit\u00e0 accessorio, \u00e8 inclusa una sola entit\u00e0. In modalit\u00e0 di inclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, a meno che non siano selezionate entit\u00e0 specifiche. In modalit\u00e0 di esclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, ad eccezione delle entit\u00e0 escluse. Per prestazioni ottimali e per evitare una indisponibilit\u00e0 imprevista, creare e associare un'istanza HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale, TV e videocamera.", + "description": "Scegliere le entit\u00e0 da includere. In modalit\u00e0 accessorio, \u00e8 inclusa una sola entit\u00e0. In modalit\u00e0 di inclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, a meno che non siano selezionate entit\u00e0 specifiche. In modalit\u00e0 di esclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, ad eccezione delle entit\u00e0 escluse. Per prestazioni ottimali, ci sar\u00e0 una HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale, TV e videocamera.", "title": "Seleziona le entit\u00e0 da includere" }, "init": { diff --git a/homeassistant/components/homekit/translations/ko.json b/homeassistant/components/homekit/translations/ko.json index 1b7276d4171..bc8e138fbaf 100644 --- a/homeassistant/components/homekit/translations/ko.json +++ b/homeassistant/components/homekit/translations/ko.json @@ -4,17 +4,23 @@ "port_name_in_use": "\uc774\ub984\uc774\ub098 \ud3ec\ud2b8\uac00 \uac19\uc740 \ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "step": { + "bridge_mode": { + "data": { + "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778" + } + }, "pairing": { - "description": "{name} \ube0c\ub9ac\uc9c0\uac00 \uc900\ube44\ub418\uba74 \"\uc54c\ub9bc\"\uc5d0\uc11c \"HomeKit \ube0c\ub9ac\uc9c0 \uc124\uc815\"\uc73c\ub85c \ud398\uc5b4\ub9c1\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", - "title": "HomeKit \ube0c\ub9ac\uc9c0 \ud398\uc5b4\ub9c1\ud558\uae30" + "description": "{name} \uc774(\uac00) \uc900\ube44\ub418\uba74 \"\uc54c\ub9bc\"\uc5d0\uc11c \"HomeKit \ube0c\ub9ac\uc9c0 \uc124\uc815\"\uc73c\ub85c \ud398\uc5b4\ub9c1\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "HomeKit \ud398\uc5b4\ub9c1\ud558\uae30" }, "user": { "data": { "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (Z-Wave \ub610\ub294 \uae30\ud0c0 \uc9c0\uc5f0\ub41c \uc2dc\uc791 \uc2dc\uc2a4\ud15c\uc744 \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)", - "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778" + "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778", + "mode": "\ubaa8\ub4dc" }, - "description": "HomeKit \ube0c\ub9ac\uc9c0\ub97c \uc0ac\uc6a9\ud558\uba74 HomeKit \uc5d0\uc11c Home Assistant \uad6c\uc131\uc694\uc18c\uc5d0 \uc561\uc138\uc2a4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. HomeKit \ube0c\ub9ac\uc9c0\ub294 \ube0c\ub9ac\uc9c0 \uc790\uccb4\ub97c \ud3ec\ud568\ud558\uc5ec \uc778\uc2a4\ud134\uc2a4\ub2f9 150\uac1c\uc758 \uc561\uc138\uc11c\ub9ac\ub85c \uc81c\ud55c\ub429\ub2c8\ub2e4. \ucd5c\ub300 \uc561\uc138\uc11c\ub9ac \uc218\ub97c \ucd08\uacfc\ud558\uc5ec \ube0c\ub9ac\uc9d5\ud558\ub824\uba74 \uc5ec\ub7ec \ub3c4\uba54\uc778\uc5d0 \ub300\ud574 \uc5ec\ub7ec \uac1c\uc758 \ud648\ud0b7 \ube0c\ub9ac\uc9c0\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4. \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 \uae30\ubcf8 \ube0c\ub9ac\uc9c0\uc758 YAML \uc744 \ud1b5\ud574\uc11c\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", - "title": "HomeKit \ube0c\ub9ac\uc9c0 \ud65c\uc131\ud654\ud558\uae30" + "description": "HomeKit \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \ud1b5\ud574 HomeKit \uc758 Home Assistant \uad6c\uc131\uc694\uc18c\uc5d0 \uc561\uc138\uc2a4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ube0c\ub9ac\uc9c0 \ubaa8\ub4dc\uc5d0\uc11c HomeKit \ube0c\ub9ac\uc9c0\ub294 \ube0c\ub9ac\uc9c0 \uc790\uccb4\ub97c \ud3ec\ud568\ud558\uc5ec \uc778\uc2a4\ud134\uc2a4\ub2f9 150 \uac1c\uc758 \uc561\uc138\uc11c\ub9ac\ub85c \uc81c\ud55c\ub429\ub2c8\ub2e4. \ucd5c\ub300 \uc561\uc138\uc11c\ub9ac \uac1c\uc218\ubcf4\ub2e4 \ub9ce\uc740 \uc218\uc758 \ube0c\ub9ac\uc9c0\ub97c \uc0ac\uc6a9\ud558\ub824\ub294 \uacbd\uc6b0, \uc11c\ub85c \ub2e4\ub978 \ub3c4\uba54\uc778\uc5d0 \ub300\ud574 \uc5ec\ub7ec\uac1c\uc758 HomeKit \ube0c\ub9ac\uc9c0\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4. \uad6c\uc131\uc694\uc18c\uc758 \uc790\uc138\ud55c \uad6c\uc131\uc740 YAML \uc744 \ud1b5\ud574\uc11c\ub9cc \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ucd5c\uc0c1\uc758 \uc131\ub2a5\uacfc \uc608\uae30\uce58 \uc54a\uc740 \uc0ac\uc6a9 \ubd88\uac00\ub2a5\ud55c \uc0c1\ud0dc\ub97c \ubc29\uc9c0\ud558\ub824\uba74 \uac01\uac01\uc758 TV \ubbf8\ub514\uc5b4 \ud50c\ub808\uc774\uc5b4\uc640 \uce74\uba54\ub77c\uc5d0 \ub300\ud574 \uc561\uc138\uc11c\ub9ac \ubaa8\ub4dc\uc5d0\uc11c \ubcc4\ub3c4\uc758 HomeKit \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\uace0 \ud398\uc5b4\ub9c1\ud574\uc8fc\uc138\uc694.", + "title": "HomeKit \ud65c\uc131\ud654\ud558\uae30" } } }, @@ -22,10 +28,10 @@ "step": { "advanced": { "data": { - "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (Z-Wave \ub610\ub294 \uae30\ud0c0 \uc9c0\uc5f0\ub41c \uc2dc\uc791 \uc2dc\uc2a4\ud15c\uc744 \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)", + "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (homekit.start \uc11c\ube44\uc2a4\ub97c \uc218\ub3d9\uc73c\ub85c \ud638\ucd9c\ud558\ub824\uba74 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)", "safe_mode": "\uc548\uc804 \ubaa8\ub4dc (\ud398\uc5b4\ub9c1\uc774 \uc2e4\ud328\ud55c \uacbd\uc6b0\uc5d0\ub9cc \ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)" }, - "description": "\uc774 \uc124\uc815\uc740 HomeKit \ube0c\ub9ac\uc9c0\uac00 \uc791\ub3d9\ud558\uc9c0 \uc54a\ub294 \uacbd\uc6b0\uc5d0\ub9cc \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "description": "\uc774 \uc124\uc815\uc740 HomeKit \uac00 \uc791\ub3d9\ud558\uc9c0 \uc54a\ub294 \uacbd\uc6b0\uc5d0\ub9cc \uc124\uc815\ud574\uc8fc\uc138\uc694.", "title": "\uace0\uae09 \uad6c\uc131\ud558\uae30" }, "cameras": { @@ -35,16 +41,22 @@ "description": "\ub124\uc774\ud2f0\ube0c H.264 \uc2a4\ud2b8\ub9bc\uc744 \uc9c0\uc6d0\ud558\ub294 \ubaa8\ub4e0 \uce74\uba54\ub77c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694. \uce74\uba54\ub77c\uac00 H.264 \uc2a4\ud2b8\ub9bc\uc744 \ucd9c\ub825\ud558\uc9c0 \uc54a\uc73c\uba74 \uc2dc\uc2a4\ud15c\uc740 \ube44\ub514\uc624\ub97c HomeKit \uc6a9 H.264 \ud3ec\ub9f7\uc73c\ub85c \ubcc0\ud658\uc2dc\ud0b5\ub2c8\ub2e4. \ud2b8\ub79c\uc2a4\ucf54\ub529 \ubcc0\ud658\uc5d0\ub294 \ub192\uc740 CPU \uc131\ub2a5\uc774 \ud544\uc694\ud558\uba70 Raspberry Pi \uc640 \uac19\uc740 \ub2e8\uc77c \ubcf4\ub4dc \ucef4\ud4e8\ud130\uc5d0\uc11c\ub294 \uc791\ub3d9\ud558\uc9c0 \uc54a\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "title": "\uce74\uba54\ub77c \ube44\ub514\uc624 \ucf54\ub371 \uc120\ud0dd\ud558\uae30" }, + "include_exclude": { + "data": { + "mode": "\ubaa8\ub4dc" + } + }, "init": { "data": { - "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778" + "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778", + "mode": "\ubaa8\ub4dc" }, - "description": "\"\ud3ec\ud568\ud560 \ub3c4\uba54\uc778\"\uc758 \uad6c\uc131\uc694\uc18c\ub294 HomeKit \uc5d0 \uc5f0\uacb0\ub429\ub2c8\ub2e4. \ub2e4\uc74c \ud654\uba74\uc5d0\uc11c \uc774 \ubaa9\ub85d\uc758 \uc81c\uc678\ud560 \uad6c\uc131\uc694\uc18c\ub97c \uc120\ud0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", - "title": "\ube0c\ub9ac\uc9c0 \ud560 \ub3c4\uba54\uc778 \uc120\ud0dd\ud558\uae30" + "description": "HomeKit \ub294 \ube0c\ub9ac\uc9c0 \ub610\ub294 \uc561\uc138\uc11c\ub9ac\ub97c \ub178\ucd9c\ud558\ub3c4\ub85d \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac \ubaa8\ub4dc\uc5d0\uc11c\ub294 \ub2e8\uc77c \uad6c\uc131\uc694\uc18c\ub9cc \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. TV \uae30\uae30 \ud074\ub798\uc2a4\uac00 \uc788\ub294 \ubbf8\ub514\uc5b4 \ud50c\ub808\uc774\uc5b4\uac00 \uc81c\ub300\ub85c \uc791\ub3d9\ud558\ub824\uba74 \uc561\uc138\uc11c\ub9ac \ubaa8\ub4dc\uac00 \ud544\uc694\ud569\ub2c8\ub2e4. \"\ud3ec\ud568\ud560 \ub3c4\uba54\uc778\"\uc758 \uad6c\uc131\uc694\uc18c\ub294 HomeKit \uc5d0 \ud3ec\ud568\ub429\ub2c8\ub2e4. \ub2e4\uc74c \ud654\uba74\uc5d0\uc11c \uc774 \ubaa9\ub85d\uc5d0 \ud3ec\ud568\ud558\uac70\ub098 \uc81c\uc678\ud560 \uad6c\uc131\uc694\uc18c\ub97c \uc120\ud0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694." }, "yaml": { "description": "\uc774 \ud56d\ubaa9\uc740 YAML \uc744 \ud1b5\ud574 \uc81c\uc5b4\ub429\ub2c8\ub2e4", - "title": "HomeKit \ube0c\ub9ac\uc9c0 \uc635\uc158 \uc870\uc815\ud558\uae30" + "title": "HomeKit \uc635\uc158 \uc870\uc815\ud558\uae30" } } } diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index 2733d6bd12d..9013723ac6c 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -4,17 +4,23 @@ "port_name_in_use": "Er is al een bridge of apparaat met dezelfde naam of poort geconfigureerd." }, "step": { + "accessory_mode": { + "data": { + "entity_id": "Entiteit" + } + }, "pairing": { "description": "Zodra de {name} klaar is, is het koppelen beschikbaar in \"Meldingen\" als \"HomeKit Bridge Setup\".", - "title": "Koppel HomeKit Bridge" + "title": "Koppel HomeKit" }, "user": { "data": { "auto_start": "Automatisch starten (uitschakelen als u Z-Wave of een ander vertraagd startsysteem gebruikt)", - "include_domains": "Domeinen om op te nemen" + "include_domains": "Domeinen om op te nemen", + "mode": "Mode" }, "description": "De HomeKit-integratie geeft u toegang tot uw Home Assistant-entiteiten in HomeKit. In bridge-modus zijn HomeKit-bruggen beperkt tot 150 accessoires per exemplaar, inclusief de brug zelf. Als u meer dan het maximale aantal accessoires wilt overbruggen, is het aan te raden om meerdere HomeKit-bridges voor verschillende domeinen te gebruiken. Gedetailleerde entiteitsconfiguratie is alleen beschikbaar via YAML voor de primaire bridge.", - "title": "Activeer HomeKit Bridge" + "title": "Selecteer domeinen die u wilt opnemen" } } }, @@ -37,7 +43,8 @@ }, "include_exclude": { "data": { - "entities": "Entiteiten" + "entities": "Entiteiten", + "mode": "Mode" } }, "init": { @@ -50,7 +57,7 @@ }, "yaml": { "description": "Deze invoer wordt beheerd via YAML", - "title": "Pas de HomeKit Bridge-opties aan" + "title": "Pas de HomeKit-opties aan" } } } diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 9a64def4156..4748fe63af2 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -19,7 +19,7 @@ "title": "Velg domener som skal inkluderes" }, "pairing": { - "description": "S\u00e5 snart {name} er klart, vil sammenkobling v\u00e6re tilgjengelig i \"Notifications\" som \"HomeKit Bridge Setup\".", + "description": "For \u00e5 fullf\u00f8re sammenkoblingen ved \u00e5 f\u00f8lge instruksjonene i \"Varsler\" under \"Sammenkobling av HomeKit\".", "title": "Koble sammen HomeKit" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "Domener \u00e5 inkludere", "mode": "Modus" }, - "description": "HomeKit-integrasjonen gir deg tilgang til Home Assistant-enhetene dine i HomeKit. I bromodus er HomeKit Bridges begrenset til 150 tilbeh\u00f8r per forekomst inkludert selve broen. Hvis du \u00f8nsker \u00e5 bygge bro over maksimalt antall tilbeh\u00f8r, anbefales det at du bruker flere HomeKit-broer for forskjellige domener. Detaljert enhetskonfigurasjon er bare tilgjengelig via YAML. For best ytelse og for \u00e5 forhindre uventet utilgjengelighet, opprett og par sammen en egen HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediaspiller og kamera.", - "title": "Aktiver HomeKit" + "description": "Velg domenene som skal inkluderes. Alle st\u00f8ttede enheter i domenet vil bli inkludert. Det opprettes en egen HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediaspiller og kamera.", + "title": "Velg domener som skal inkluderes" } } }, @@ -55,7 +55,7 @@ "entities": "Entiteter", "mode": "Modus" }, - "description": "Velg enhetene som skal inkluderes. I tilbeh\u00f8rsmodus er bare \u00e9n enkelt enhet inkludert. I bridge include-modus inkluderes alle enheter i domenet med mindre bestemte enheter er valgt. I brounnlatingsmodus inkluderes alle enheter i domenet, med unntak av de utelatte enhetene. For best mulig ytelse, og for \u00e5 forhindre uventet utilgjengelighet, opprett og par en separat HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediespiller og kamera.", + "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 vil et eget HomeKit-tilbeh\u00f8r v\u00e6re TV-mediaspiller og kamera.", "title": "Velg enheter som skal inkluderes" }, "init": { diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index 2679a4de20a..ef35ff667c4 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -19,7 +19,7 @@ "title": "Wybierz uwzgl\u0119dniane domeny" }, "pairing": { - "description": "Gdy tylko {name} b\u0119dzie gotowy, opcja parowania b\u0119dzie dost\u0119pna w \u201ePowiadomieniach\u201d jako \u201eKonfiguracja mostka HomeKit\u201d.", + "description": "Aby doko\u0144czy\u0107 parowanie, post\u0119puj wg instrukcji \u201eParowanie HomeKit\u201d w \u201ePowiadomieniach\u201d.", "title": "Parowanie z HomeKit" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "Domeny do uwzgl\u0119dnienia", "mode": "Tryb" }, - "description": "Integracja HomeKit pozwala na dost\u0119p do Twoich encji Home Assistant w HomeKit. W trybie \"Mostka\", mostki HomeKit s\u0105 ograniczone do 150 urz\u0105dze\u0144, w\u0142\u0105czaj\u0105c w to sam mostek. Je\u015bli chcesz wi\u0119cej ni\u017c dozwolona maksymalna liczba urz\u0105dze\u0144, zaleca si\u0119 u\u017cywanie wielu most\u00f3w HomeKit dla r\u00f3\u017cnych domen. Szczeg\u00f3\u0142owa konfiguracja encji jest dost\u0119pna tylko w trybie YAML dla g\u0142\u00f3wnego mostka. Dla najlepszej wydajno\u015bci oraz by zapobiec nieprzewidzianej niedost\u0119pno\u015bci urz\u0105dzenia, utw\u00f3rz i sparuj oddzieln\u0105 instancj\u0119 HomeKit w trybie akcesorium dla ka\u017cdego media playera oraz kamery.", - "title": "Aktywacja HomeKit" + "description": "Wybierz domeny do uwzgl\u0119dnienia. Wszystkie wspierane encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione. W trybie akcesorium, oddzielna instancja HomeKit zostanie utworzona dla ka\u017cdego tv media playera oraz kamery.", + "title": "Wybierz uwzgl\u0119dniane domeny" } } }, @@ -55,7 +55,7 @@ "entities": "Encje", "mode": "Tryb" }, - "description": "Wybierz encje, kt\u00f3re maj\u0105 by\u0107 uwzgl\u0119dnione. W trybie \"Akcesorium\" tylko jedna encja jest uwzgl\u0119dniona. W trybie \"Uwzgl\u0119dnij mostek\", wszystkie encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione, chyba \u017ce wybrane s\u0105 tylko konkretne encje. W trybie \"Wyklucz mostek\", wszystkie encje b\u0119d\u0105 uwzgl\u0119dnione, z wyj\u0105tkiem tych wybranych. Dla najlepszej wydajno\u015bci oraz by zapobiec nieprzewidzianej niedost\u0119pno\u015bci urz\u0105dzenia, utw\u00f3rz i sparuj oddzieln\u0105 instancj\u0119 HomeKit w trybie akcesorium dla ka\u017cdego media playera oraz kamery.", + "description": "Wybierz encje, kt\u00f3re maj\u0105 by\u0107 uwzgl\u0119dnione. W trybie \"Akcesorium\" tylko jedna encja jest uwzgl\u0119dniona. W trybie \"Uwzgl\u0119dnij mostek\", wszystkie encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione, chyba \u017ce wybrane s\u0105 tylko konkretne encje. W trybie \"Wyklucz mostek\", wszystkie encje b\u0119d\u0105 uwzgl\u0119dnione, z wyj\u0105tkiem tych wybranych. Dla najlepszej wydajno\u015bci, zostanie utworzone oddzielne akcesorium HomeKit dla ka\u017cdego tv media playera oraz kamery.", "title": "Wybierz encje, kt\u00f3re maj\u0105 by\u0107 uwzgl\u0119dnione" }, "init": { diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json index 6cf96c2dd78..d00744e4cb4 100644 --- a/homeassistant/components/homekit/translations/ru.json +++ b/homeassistant/components/homekit/translations/ru.json @@ -7,19 +7,29 @@ "accessory_mode": { "data": { "entity_id": "\u041e\u0431\u044a\u0435\u043a\u0442" - } + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u043e\u0431\u044a\u0435\u043a\u0442.", + "title": "\u0412\u044b\u0431\u043e\u0440 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" + }, + "bridge_mode": { + "data": { + "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u044b. \u0411\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0438\u0437 \u0434\u043e\u043c\u0435\u043d\u0430.", + "title": "\u0412\u044b\u0431\u043e\u0440 \u0434\u043e\u043c\u0435\u043d\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" }, "pairing": { - "description": "\u041a\u0430\u043a \u0442\u043e\u043b\u044c\u043a\u043e {name} \u0431\u0443\u0434\u0435\u0442 \u0433\u043e\u0442\u043e\u0432\u043e, \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0432 \"\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f\u0445\" \u043a\u0430\u043a \"HomeKit Bridge Setup\".", + "description": "\u0414\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u0441\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u043c \u0432 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0438 \"HomeKit Pairing\".", "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 HomeKit" }, "user": { "data": { "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Z-Wave \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043e\u0442\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0430)", - "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" + "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b", + "mode": "\u0420\u0435\u0436\u0438\u043c" }, - "description": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u043e\u0431\u044a\u0435\u043a\u0442\u0430\u043c Home Assistant \u0447\u0435\u0440\u0435\u0437 HomeKit. HomeKit Bridge \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d 150 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430\u043c\u0438 \u043d\u0430 \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440, \u0432\u043a\u043b\u044e\u0447\u0430\u044f \u0441\u0430\u043c \u0431\u0440\u0438\u0434\u0436. \u0415\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u0431\u043e\u043b\u044c\u0448\u0435, \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e HomeKit Bridge \u0434\u043b\u044f \u0440\u0430\u0437\u043d\u044b\u0445 \u0434\u043e\u043c\u0435\u043d\u043e\u0432. \u0414\u0435\u0442\u0430\u043b\u044c\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437 YAML. \u0414\u043b\u044f \u043b\u0443\u0447\u0448\u0435\u0439 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0438 \u043f\u0440\u0435\u0434\u043e\u0442\u0432\u0440\u0430\u0449\u0435\u043d\u0438\u044f \u043d\u0435\u0438\u0441\u043f\u0440\u0430\u0432\u043d\u043e\u0441\u0442\u0435\u0439 \u0441\u043e\u0437\u0434\u0430\u0439\u0442\u0435 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0443\u044e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440\u0430 \u0438\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u044b.", - "title": "HomeKit" + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u044b. \u0411\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0438\u0437 \u0434\u043e\u043c\u0435\u043d\u0430. \u0414\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440\u0430 \u0438\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u044b \u0431\u0443\u0434\u0435\u0442 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430.", + "title": "\u0412\u044b\u0431\u043e\u0440 \u0434\u043e\u043c\u0435\u043d\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" } } }, @@ -45,7 +55,7 @@ "entities": "\u041e\u0431\u044a\u0435\u043a\u0442\u044b", "mode": "\u0420\u0435\u0436\u0438\u043c" }, - "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u043c\u043e\u0436\u043d\u043e \u043f\u0435\u0440\u0435\u0434\u0430\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u043e\u0431\u044a\u0435\u043a\u0442. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u043c\u043e\u0441\u0442\u0430 \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b, \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0449\u0438\u0435 \u0434\u043e\u043c\u0435\u043d\u0443, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0432\u044b\u0431\u0440\u0430\u043d\u044b \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b, \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0449\u0438\u0435 \u0434\u043e\u043c\u0435\u043d\u0443, \u043a\u0440\u043e\u043c\u0435 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044b\u0445. \u0414\u043b\u044f \u043b\u0443\u0447\u0448\u0435\u0439 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0438 \u043f\u0440\u0435\u0434\u043e\u0442\u0432\u0440\u0430\u0449\u0435\u043d\u0438\u044f \u043d\u0435\u0438\u0441\u043f\u0440\u0430\u0432\u043d\u043e\u0441\u0442\u0435\u0439 \u0441\u043e\u0437\u0434\u0430\u0439\u0442\u0435 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0443\u044e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440\u0430 \u0438\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u044b.", + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u043c\u043e\u0436\u043d\u043e \u043f\u0435\u0440\u0435\u0434\u0430\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u043e\u0431\u044a\u0435\u043a\u0442. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u043c\u043e\u0441\u0442\u0430 \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b, \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0449\u0438\u0435 \u0434\u043e\u043c\u0435\u043d\u0443, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0432\u044b\u0431\u0440\u0430\u043d\u044b \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b, \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0449\u0438\u0435 \u0434\u043e\u043c\u0435\u043d\u0443, \u043a\u0440\u043e\u043c\u0435 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044b\u0445. \u0414\u043b\u044f \u0443\u043b\u0443\u0447\u0448\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0443\u044e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440\u0430 \u0438\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u044b.", "title": "\u0412\u044b\u0431\u043e\u0440 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" }, "init": { diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index 605263c4489..95a0782cf12 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -19,7 +19,7 @@ "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df" }, "pairing": { - "description": "\u65bc {name} \u5c31\u7dd2\u5f8c\u3001\u5c07\u6703\u65bc\u300c\u901a\u77e5\u300d\u4e2d\u986f\u793a\u300cHomeKit Bridge \u8a2d\u5b9a\u300d\u7684\u914d\u5c0d\u8cc7\u8a0a\u3002", + "description": "\u6b32\u5b8c\u6210\u914d\u5c0d\u3001\u8acb\u8ddf\u96a8\u300c\u901a\u77e5\u300d\u5167\u7684\u300cHomekit \u914d\u5c0d\u300d\u6307\u5f15\u3002", "title": "\u914d\u5c0d HomeKit" }, "user": { @@ -28,8 +28,8 @@ "include_domains": "\u5305\u542b\u7db2\u57df", "mode": "\u6a21\u5f0f" }, - "description": "HomeKit \u6574\u5408\u5c07\u53ef\u5141\u8a31\u65bc Homekit \u4e2d\u4f7f\u7528 Home Assistant \u5be6\u9ad4\u3002\u65bc\u6a4b\u63a5\u6a21\u5f0f\u4e0b\u3001HomeKit Bridges \u6700\u9ad8\u9650\u5236\u70ba 150 \u500b\u914d\u4ef6\u3001\u5305\u542b Bridge \u672c\u8eab\u3002\u5047\u5982\u60f3\u8981\u4f7f\u7528\u8d85\u904e\u9650\u5236\u4ee5\u4e0a\u7684\u914d\u4ef6\uff0c\u5efa\u8b70\u53ef\u4ee5\u4e0d\u540c\u7db2\u57df\u4f7f\u7528\u591a\u500b HomeKit bridges \u9054\u5230\u6b64\u9700\u6c42\u3002\u50c5\u80fd\u65bc\u4e3b Bridge \u4ee5 YAML \u8a2d\u5b9a\u8a73\u7d30\u5be6\u9ad4\u3002\u70ba\u53d6\u5f97\u6700\u4f73\u6548\u80fd\u3001\u4e26\u907f\u514d\u672a\u9810\u671f\u7121\u6cd5\u4f7f\u7528\u72c0\u614b\uff0c\u96fb\u8996\u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\uff0c\u8acb\u65bc Homekit \u914d\u4ef6\u6a21\u5f0f\u4e2d\u5206\u5225\u9032\u884c\u914d\u5c0d\u3002", - "title": "\u555f\u7528 HomeKit" + "description": "\u9078\u64c7\u6240\u8981\u5305\u542b\u7684\u7db2\u57df\uff0c\u6240\u6709\u8a72\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u5c07\u6703\u88ab\u5305\u542b\u3002 \u5176\u4ed6 Homekit \u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\u5be6\u4f8b\uff0c\u5c07\u6703\u4ee5\u914d\u4ef6\u6a21\u5f0f\u65b0\u589e\u3002", + "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df" } } }, @@ -55,7 +55,7 @@ "entities": "\u5be6\u9ad4", "mode": "\u6a21\u5f0f" }, - "description": "\u9078\u64c7\u8981\u5305\u542b\u7684\u5be6\u9ad4\u3002\u65bc\u914d\u4ef6\u6a21\u5f0f\u4e0b\u3001\u50c5\u6709\u55ae\u4e00\u5be6\u9ad4\u5c07\u6703\u5305\u542b\u3002\u65bc\u6a4b\u63a5\u5305\u542b\u6a21\u5f0f\u4e0b\u3001\u6240\u6709\u7db2\u57df\u7684\u5be6\u9ad4\u90fd\u5c07\u5305\u542b\uff0c\u9664\u975e\u9078\u64c7\u7279\u5b9a\u7684\u5be6\u9ad4\u3002\u65bc\u6a4b\u63a5\u6392\u9664\u6a21\u5f0f\u4e2d\u3001\u6240\u6709\u7db2\u57df\u4e2d\u7684\u5be6\u9ad4\u90fd\u5c07\u5305\u542b\uff0c\u9664\u4e86\u6392\u9664\u7684\u5be6\u9ad4\u3002\u70ba\u53d6\u5f97\u6700\u4f73\u6548\u80fd\u3001\u4e26\u907f\u514d\u672a\u9810\u671f\u7121\u6cd5\u4f7f\u7528\u72c0\u614b\uff0c\u96fb\u8996\u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\uff0c\u8acb\u65bc Homekit \u914d\u4ef6\u6a21\u5f0f\u4e2d\u5206\u5225\u9032\u884c\u914d\u5c0d\u3002", + "description": "\u9078\u64c7\u8981\u5305\u542b\u7684\u5be6\u9ad4\u3002\u65bc\u914d\u4ef6\u6a21\u5f0f\u4e0b\u3001\u50c5\u6709\u55ae\u4e00\u5be6\u9ad4\u5c07\u6703\u5305\u542b\u3002\u65bc\u6a4b\u63a5\u5305\u542b\u6a21\u5f0f\u4e0b\u3001\u6240\u6709\u7db2\u57df\u7684\u5be6\u9ad4\u90fd\u5c07\u5305\u542b\uff0c\u9664\u975e\u9078\u64c7\u7279\u5b9a\u7684\u5be6\u9ad4\u3002\u65bc\u6a4b\u63a5\u6392\u9664\u6a21\u5f0f\u4e2d\u3001\u6240\u6709\u7db2\u57df\u4e2d\u7684\u5be6\u9ad4\u90fd\u5c07\u5305\u542b\uff0c\u9664\u4e86\u6392\u9664\u7684\u5be6\u9ad4\u3002\u70ba\u53d6\u5f97\u6700\u4f73\u6548\u80fd\u3001\u96fb\u8996\u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\uff0c\u5c07\u65bc Homekit \u914d\u4ef6\u6a21\u5f0f\u9032\u884c\u3002", "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u5be6\u9ad4" }, "init": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index b61a2c57612..48f7ad9b064 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -from haffmpeg.core import HAFFmpeg +from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg from pyhap.camera import ( VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, @@ -115,6 +115,7 @@ RESOLUTIONS = [ VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] FFMPEG_WATCH_INTERVAL = timedelta(seconds=5) +FFMPEG_LOGGER = "ffmpeg_logger" FFMPEG_WATCHER = "ffmpeg_watcher" FFMPEG_PID = "ffmpeg_pid" SESSION_ID = "session_id" @@ -239,7 +240,7 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_doorbell_state(state) - async def run_handler(self): + async def run(self): """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -258,7 +259,7 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_doorbell_state_event, ) - await super().run_handler() + await super().run() @callback def _async_update_motion_state_event(self, event): @@ -372,7 +373,12 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.debug("FFmpeg output settings: %s", output) stream = HAFFmpeg(self._ffmpeg.binary) opened = await stream.open( - cmd=[], input_source=input_source, output=output, stdout_pipe=False + cmd=[], + input_source=input_source, + output=output, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + stdout_pipe=False, ) if not opened: _LOGGER.error("Failed to open ffmpeg stream") @@ -387,9 +393,14 @@ class Camera(HomeAccessory, PyhapCamera): session_info["stream"] = stream session_info[FFMPEG_PID] = stream.process.pid + stderr_reader = await stream.get_reader(source=FFMPEG_STDERR) + async def watch_session(_): await self._async_ffmpeg_watch(session_info["id"]) + session_info[FFMPEG_LOGGER] = asyncio.create_task( + self._async_log_stderr_stream(stderr_reader) + ) session_info[FFMPEG_WATCHER] = async_track_time_interval( self.hass, watch_session, @@ -398,6 +409,16 @@ class Camera(HomeAccessory, PyhapCamera): return await self._async_ffmpeg_watch(session_info["id"]) + async def _async_log_stderr_stream(self, stderr_reader): + """Log output from ffmpeg.""" + _LOGGER.debug("%s: ffmpeg: started", self.display_name) + while True: + line = await stderr_reader.readline() + if line == b"": + return + + _LOGGER.debug("%s: ffmpeg: %s", self.display_name, line.rstrip()) + async def _async_ffmpeg_watch(self, session_id): """Check to make sure ffmpeg is still running and cleanup if not.""" ffmpeg_pid = self.sessions[session_id][FFMPEG_PID] @@ -415,6 +436,7 @@ class Camera(HomeAccessory, PyhapCamera): if FFMPEG_WATCHER not in self.sessions[session_id]: return self.sessions[session_id].pop(FFMPEG_WATCHER)() + self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" @@ -444,13 +466,10 @@ class Camera(HomeAccessory, PyhapCamera): """Reconfigure the stream so that it uses the given ``stream_config``.""" return True - def get_snapshot(self, image_size): + async def async_get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" return scale_jpeg_camera_image( - asyncio.run_coroutine_threadsafe( - self.hass.components.camera.async_get_image(self.entity_id), - self.hass.loop, - ).result(), + await self.hass.components.camera.async_get_image(self.entity_id), image_size["image-width"], image_size["image-height"], ) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 75cabf14483..ca375bb6f37 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.event import async_track_state_change_event -from .accessories import TYPES, HomeAccessory, debounce +from .accessories import TYPES, HomeAccessory from .const import ( ATTR_OBSTRUCTION_DETECTED, CHAR_CURRENT_DOOR_STATE, @@ -113,7 +113,7 @@ class GarageDoorOpener(HomeAccessory): self.async_update_state(state) - async def run_handler(self): + async def run(self): """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -125,7 +125,7 @@ class GarageDoorOpener(HomeAccessory): self._async_update_obstruction_event, ) - await super().run_handler() + await super().run() @callback def _async_update_obstruction_event(self, event): @@ -158,11 +158,11 @@ class GarageDoorOpener(HomeAccessory): if value == HK_DOOR_OPEN: if self.char_current_state.value != value: self.char_current_state.set_value(HK_DOOR_OPENING) - self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) + self.async_call_service(DOMAIN, SERVICE_OPEN_COVER, params) elif value == HK_DOOR_CLOSED: if self.char_current_state.value != value: self.char_current_state.set_value(HK_DOOR_CLOSING) - self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params) + self.async_call_service(DOMAIN, SERVICE_CLOSE_COVER, params) @callback def async_update_state(self, new_state): @@ -231,9 +231,10 @@ class OpeningDeviceBase(HomeAccessory): """Stop the cover motion from HomeKit.""" if value != 1: return - self.call_service(DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id}) + self.async_call_service( + DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id} + ) - @debounce def set_tilt(self, value): """Set tilt to value if call came from HomeKit.""" _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) @@ -244,7 +245,7 @@ class OpeningDeviceBase(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value} - self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + self.async_call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) @callback def async_update_state(self, new_state): @@ -284,12 +285,11 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - @debounce def move_cover(self, value): """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} - self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) + self.async_call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) @callback def async_update_state(self, new_state): @@ -360,7 +360,6 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - @debounce def move_cover(self, value): """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) @@ -379,7 +378,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): service, position = (SERVICE_CLOSE_COVER, 0) params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) # Snap the current/target position to the expected final position. self.char_current_position.set_value(position) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1142a476bc5..1efb3b6c8be 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -6,14 +6,17 @@ from pyhap.const import CATEGORY_FAN from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, - ATTR_SPEED, - ATTR_SPEED_LIST, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, - SERVICE_SET_SPEED, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, @@ -31,12 +34,15 @@ from homeassistant.core import callback from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, + CHAR_NAME, + CHAR_ON, CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, + PROP_MIN_STEP, SERV_FANV2, + SERV_SWITCH, ) -from .util import HomeKitSpeedMapping _LOGGER = logging.getLogger(__name__) @@ -55,24 +61,24 @@ class Fan(HomeAccessory): state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1) + preset_modes = state.attributes.get(ATTR_PRESET_MODES) if features & SUPPORT_DIRECTION: chars.append(CHAR_ROTATION_DIRECTION) if features & SUPPORT_OSCILLATE: chars.append(CHAR_SWING_MODE) if features & SUPPORT_SET_SPEED: - speed_list = self.hass.states.get(self.entity_id).attributes.get( - ATTR_SPEED_LIST - ) - self.speed_mapping = HomeKitSpeedMapping(speed_list) chars.append(CHAR_ROTATION_SPEED) serv_fan = self.add_preload_service(SERV_FANV2, chars) + self.set_primary_service(serv_fan) self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) self.char_direction = None self.char_speed = None self.char_swing = None + self.preset_mode_chars = {} if CHAR_ROTATION_DIRECTION in chars: self.char_direction = serv_fan.configure_char( @@ -83,7 +89,27 @@ class Fan(HomeAccessory): # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by async_update_state # to set to the correct initial value. - self.char_speed = serv_fan.configure_char(CHAR_ROTATION_SPEED, value=100) + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: percentage_step}, + ) + + if preset_modes: + for preset_mode in preset_modes: + preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_fan.add_linked_service(preset_serv) + preset_serv.configure_char( + CHAR_NAME, value=f"{self.display_name} {preset_mode}" + ) + + self.preset_mode_chars[preset_mode] = preset_serv.configure_char( + CHAR_ON, + value=False, + setter_callback=lambda value, preset_mode=preset_mode: self.set_preset_mode( + value, preset_mode + ), + ) if CHAR_SWING_MODE in chars: self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) @@ -117,35 +143,46 @@ class Fan(HomeAccessory): # We always do this LAST to ensure they # get the speed they asked for if CHAR_ROTATION_SPEED in char_values: - self.set_speed(char_values[CHAR_ROTATION_SPEED]) + self.set_percentage(char_values[CHAR_ROTATION_SPEED]) + + def set_preset_mode(self, value, preset_mode): + """Set preset_mode if call came from HomeKit.""" + _LOGGER.debug( + "%s: Set preset_mode %s to %d", self.entity_id, preset_mode, value + ) + params = {ATTR_ENTITY_ID: self.entity_id} + if value: + params[ATTR_PRESET_MODE] = preset_mode + self.async_call_service(DOMAIN, SERVICE_SET_PRESET_MODE, params) + else: + self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) def set_state(self, value): """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_direction(self, value): """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set direction to %d", self.entity_id, value) direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} - self.call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) + self.async_call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) def set_oscillating(self, value): """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set oscillating to %d", self.entity_id, value) oscillating = value == 1 params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} - self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) + self.async_call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) - def set_speed(self, value): + def set_percentage(self, value): """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set speed to %d", self.entity_id, value) - speed = self.speed_mapping.speed_to_states(value) - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SPEED: speed} - self.call_service(DOMAIN, SERVICE_SET_SPEED, params, speed) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_PERCENTAGE: value} + self.async_call_service(DOMAIN, SERVICE_SET_PERCENTAGE, params, value) @callback def async_update_state(self, new_state): @@ -169,24 +206,22 @@ class Fan(HomeAccessory): if self.char_speed is not None and state != STATE_OFF: # We do not change the homekit speed when turning off # as it will clear the restore state - speed = new_state.attributes.get(ATTR_SPEED) - hk_speed_value = self.speed_mapping.speed_to_homekit(speed) - if hk_speed_value is not None and self.char_speed.value != hk_speed_value: - # If the homeassistant component reports its speed as the first entry - # in its speed list but is not off, the hk_speed_value is 0. But 0 - # is a special value in homekit. When you turn on a homekit accessory - # it will try to restore the last rotation speed state which will be - # the last value saved by char_speed.set_value. But if it is set to - # 0, HomeKit will update the rotation speed to 100 as it thinks 0 is - # off. - # - # Therefore, if the hk_speed_value is 0 and the device is still on, - # the rotation speed is mapped to 1 otherwise the update is ignored - # in order to avoid this incorrect behavior. - if hk_speed_value == 0 and state == STATE_ON: - hk_speed_value = 1 - if self.char_speed.value != hk_speed_value: - self.char_speed.set_value(hk_speed_value) + percentage = new_state.attributes.get(ATTR_PERCENTAGE) + # If the homeassistant component reports its speed as the first entry + # in its speed list but is not off, the hk_speed_value is 0. But 0 + # is a special value in homekit. When you turn on a homekit accessory + # it will try to restore the last rotation speed state which will be + # the last value saved by char_speed.set_value. But if it is set to + # 0, HomeKit will update the rotation speed to 100 as it thinks 0 is + # off. + # + # Therefore, if the hk_speed_value is 0 and the device is still on, + # the rotation speed is mapped to 1 otherwise the update is ignored + # in order to avoid this incorrect behavior. + if percentage == 0 and state == STATE_ON: + percentage = 1 + if percentage is not None and self.char_speed.value != percentage: + self.char_speed.set_value(percentage) # Handle Oscillating if self.char_swing is not None: @@ -195,3 +230,9 @@ class Fan(HomeAccessory): hk_oscillating = 1 if oscillating else 0 if self.char_swing.value != hk_oscillating: self.char_swing.set_value(hk_oscillating) + + current_preset_mode = new_state.attributes.get(ATTR_PRESET_MODE) + for preset_mode, char in self.preset_mode_chars.items(): + hk_value = 1 if preset_mode == current_preset_mode else 0 + if char.value != hk_value: + char.set_value(hk_value) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index dd829206b0c..6e1978d9499 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -143,7 +143,7 @@ class HumidifierDehumidifier(HomeAccessory): if humidity_state: self._async_update_current_humidity(humidity_state) - async def run_handler(self): + async def run(self): """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -155,7 +155,7 @@ class HumidifierDehumidifier(HomeAccessory): self.async_update_current_humidity_event, ) - await super().run_handler() + await super().run() @callback def async_update_current_humidity_event(self, event): @@ -201,7 +201,7 @@ class HumidifierDehumidifier(HomeAccessory): ) if CHAR_ACTIVE in char_values: - self.call_service( + self.async_call_service( DOMAIN, SERVICE_TURN_ON if char_values[CHAR_ACTIVE] else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id}, @@ -210,7 +210,7 @@ class HumidifierDehumidifier(HomeAccessory): if self._target_humidity_char_name in char_values: humidity = round(char_values[self._target_humidity_char_name]) - self.call_service( + self.async_call_service( DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: humidity}, diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 086934ea6f7..8be1580537d 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -139,7 +139,7 @@ class Light(HomeAccessory): params[ATTR_HS_COLOR] = color events.append(f"set color at {color}") - self.call_service(DOMAIN, service, params, ", ".join(events)) + self.async_call_service(DOMAIN, service, params, ", ".join(events)) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index af5b24c50e1..140940dda47 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -61,7 +61,7 @@ class Lock(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 901ac2173f4..b54b62372f9 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -177,7 +177,7 @@ class MediaPlayer(HomeAccessory): _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_play_pause(self, value): """Move switch state to value if call came from HomeKit.""" @@ -186,7 +186,7 @@ class MediaPlayer(HomeAccessory): ) service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_play_stop(self, value): """Move switch state to value if call came from HomeKit.""" @@ -195,7 +195,7 @@ class MediaPlayer(HomeAccessory): ) service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_toggle_mute(self, value): """Move switch state to value if call came from HomeKit.""" @@ -203,7 +203,7 @@ class MediaPlayer(HomeAccessory): '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value ) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} - self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) + self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) @callback def async_update_state(self, new_state): @@ -344,7 +344,7 @@ class TelevisionMediaPlayer(HomeAccessory): _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_mute(self, value): """Move switch state to value if call came from HomeKit.""" @@ -352,27 +352,27 @@ class TelevisionMediaPlayer(HomeAccessory): '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value ) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} - self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) + self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) def set_volume(self, value): """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Set volume to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_LEVEL: value} - self.call_service(DOMAIN, SERVICE_VOLUME_SET, params) + self.async_call_service(DOMAIN, SERVICE_VOLUME_SET, params) def set_volume_step(self, value): """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Step volume by %s", self.entity_id, value) service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) def set_input_source(self, value): """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source = self.sources[value] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source} - self.call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) + self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) def set_remote_key(self, value): """Send remote key value if call came from HomeKit.""" @@ -392,7 +392,7 @@ class TelevisionMediaPlayer(HomeAccessory): else: service = SERVICE_MEDIA_PLAY_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) else: # Unhandled keys can be handled by listening to the event bus self.hass.bus.fire( diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index feae1b5cd06..acbf636c1c3 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -150,7 +150,7 @@ class SecuritySystem(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index b3ee8a06497..1ce6c364896 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -80,7 +80,7 @@ class Outlet(HomeAccessory): _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) @callback def async_update_state(self, new_state): @@ -131,7 +131,7 @@ class Switch(HomeAccessory): return params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.call_service(self._domain, service, params) + self.async_call_service(self._domain, service, params) if self.activate_only: call_later(self.hass, 1, self.reset_switch) @@ -169,7 +169,9 @@ class Vacuum(Switch): sup_return_home = features & SUPPORT_RETURN_HOME service = SERVICE_RETURN_TO_BASE if sup_return_home else SERVICE_TURN_OFF - self.call_service(VACUUM_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id}) + self.async_call_service( + VACUUM_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id} + ) @callback def async_update_state(self, new_state): @@ -209,7 +211,7 @@ class Valve(HomeAccessory): self.char_in_use.set_value(value) params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.call_service(DOMAIN, service, params) + self.async_call_service(DOMAIN, service, params) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index a1c13432614..2eb63f4c840 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -356,7 +356,7 @@ class Thermostat(HomeAccessory): if service: params[ATTR_ENTITY_ID] = self.entity_id - self.call_service( + self.async_call_service( DOMAIN_CLIMATE, service, params, @@ -407,7 +407,7 @@ class Thermostat(HomeAccessory): """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} - self.call_service( + self.async_call_service( DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{PERCENTAGE}" ) @@ -584,7 +584,7 @@ class WaterHeater(HomeAccessory): _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) temperature = temperature_to_states(value, self._unit) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} - self.call_service( + self.async_call_service( DOMAIN_WATER_HEATER, SERVICE_SET_TEMPERATURE_WATER_HEATER, params, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 453ae13d846..46b893bb96d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -1,5 +1,4 @@ """Collection of useful functions for the HomeKit component.""" -from collections import OrderedDict, namedtuple import io import ipaddress import logging @@ -11,11 +10,18 @@ import socket import pyqrcode import voluptuous as vol -from homeassistant.components import binary_sensor, fan, media_player, sensor +from homeassistant.components import binary_sensor, media_player, sensor +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.media_player import ( + DEVICE_CLASS_TV, + DOMAIN as MEDIA_PLAYER_DOMAIN, +) from homeassistant.const import ( ATTR_CODE, + ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, CONF_NAME, + CONF_PORT, CONF_TYPE, TEMP_CELSIUS, ) @@ -66,7 +72,6 @@ from .const import ( FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, - HOMEKIT_FILE, HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, TYPE_FAUCET, @@ -310,56 +315,6 @@ def validate_media_player_features(state, feature_list): return True -SpeedRange = namedtuple("SpeedRange", ("start", "target")) -SpeedRange.__doc__ += """ Maps Home Assistant speed \ -values to percentage based HomeKit speeds. -start: Start of the range (inclusive). -target: Percentage to use to determine HomeKit percentages \ -from HomeAssistant speed. -""" - - -class HomeKitSpeedMapping: - """Supports conversion between Home Assistant and HomeKit fan speeds.""" - - def __init__(self, speed_list): - """Initialize a new SpeedMapping object.""" - if speed_list[0] != fan.SPEED_OFF: - _LOGGER.warning( - "%s does not contain the speed setting " - "%s as its first element. " - "Assuming that %s is equivalent to 'off'", - speed_list, - fan.SPEED_OFF, - speed_list[0], - ) - self.speed_ranges = OrderedDict() - list_size = len(speed_list) - for index, speed in enumerate(speed_list): - # By dividing by list_size -1 the following - # desired attributes hold true: - # * index = 0 => 0%, equal to "off" - # * index = len(speed_list) - 1 => 100 % - # * all other indices are equally distributed - target = index * 100 / (list_size - 1) - start = index * 100 / list_size - self.speed_ranges[speed] = SpeedRange(start, target) - - def speed_to_homekit(self, speed): - """Map Home Assistant speed state to HomeKit speed.""" - if speed is None: - return None - speed_range = self.speed_ranges[speed] - return round(speed_range.target) - - def speed_to_states(self, speed): - """Map HomeKit speed to Home Assistant speed state.""" - for state, speed_range in reversed(self.speed_ranges.items()): - if speed_range.start <= speed: - return state - return list(self.speed_ranges)[0] - - def show_setup_message(hass, entry_id, bridge_name, pincode, uri): """Display persistent notification with setup information.""" pin = pincode.decode() @@ -379,9 +334,7 @@ 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 Bridge Setup", entry_id - ) + hass.components.persistent_notification.create(message, "HomeKit Pairing", entry_id) def dismiss_setup_message(hass, entry_id): @@ -460,24 +413,6 @@ def format_sw_version(version): return None -def migrate_filesystem_state_data_for_primary_imported_entry_id( - hass: HomeAssistant, entry_id: str -): - """Migrate the old paths to the storage directory.""" - legacy_persist_file_path = hass.config.path(HOMEKIT_FILE) - if os.path.exists(legacy_persist_file_path): - os.rename( - legacy_persist_file_path, get_persist_fullpath_for_entry_id(hass, entry_id) - ) - - legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids") - if os.path.exists(legacy_aid_storage_path): - os.rename( - legacy_aid_storage_path, - get_aid_storage_fullpath_for_entry_id(hass, entry_id), - ) - - def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str): """Remove the state files from disk.""" persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id) @@ -496,7 +431,7 @@ def _get_test_socket(): return test_socket -def port_is_available(port: int): +def port_is_available(port: int) -> bool: """Check to see if a port is available.""" test_socket = _get_test_socket() try: @@ -507,10 +442,24 @@ def port_is_available(port: int): return True -def find_next_available_port(start_port: int): +async def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: + """Find the next available port not assigned to a config entry.""" + exclude_ports = set() + for entry in hass.config_entries.async_entries(DOMAIN): + if CONF_PORT in entry.data: + exclude_ports.add(entry.data[CONF_PORT]) + + return await hass.async_add_executor_job( + _find_next_available_port, start_port, exclude_ports + ) + + +def _find_next_available_port(start_port: int, exclude_ports: set) -> int: """Find the next available port starting with the given port.""" test_socket = _get_test_socket() for port in range(start_port, MAX_PORT): + if port in exclude_ports: + continue try: test_socket.bind(("", port)) return port @@ -520,7 +469,7 @@ def find_next_available_port(start_port: int): continue -def pid_is_alive(pid): +def pid_is_alive(pid) -> bool: """Check to see if a process is alive.""" try: os.kill(pid, 0) @@ -528,3 +477,30 @@ def pid_is_alive(pid): except OSError: pass return False + + +def accessory_friendly_name(hass_name, accessory): + """Return the combined name for the accessory. + + The mDNS name and the Home Assistant config entry + name are usually different which means they need to + see both to identify the accessory. + """ + accessory_mdns_name = accessory.display_name + if hass_name.startswith(accessory_mdns_name): + return hass_name + return f"{hass_name} ({accessory_mdns_name})" + + +def state_needs_accessory_mode(state): + """Return if the entity represented by the state must be paired in accessory mode.""" + if state.domain == CAMERA_DOMAIN: + return True + + if ( + state.domain == MEDIA_PLAYER_DOMAIN + and state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TV + ): + return True + + return False diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index e046a131a6b..38a41617c6a 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -253,7 +253,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): await self.async_set_unique_id(normalize_hkid(hkid)) self._abort_if_unique_id_configured() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["hkid"] = hkid if paired: @@ -392,7 +391,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): @callback def _async_step_pair_show_form(self, errors=None): - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 placeholders = {"name": self.name} self.context["title_placeholders"] = {"name": self.name} diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 476d0f2c8e5..e2cdf7b3cfd 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -5,10 +5,6 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, @@ -26,13 +22,6 @@ DIRECTION_TO_HK = { } HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} -SPEED_TO_PCNT = { - SPEED_HIGH: 100, - SPEED_MEDIUM: 50, - SPEED_LOW: 25, - SPEED_OFF: 0, -} - class BaseHomeKitFan(HomeKitEntity, FanEntity): """Representation of a Homekit fan.""" @@ -56,30 +45,12 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): return self.service.value(self.on_characteristic) == 1 @property - def speed(self): - """Return the current speed.""" + def percentage(self): + """Return the current speed percentage.""" if not self.is_on: - return SPEED_OFF + return 0 - rotation_speed = self.service.value(CharacteristicsTypes.ROTATION_SPEED) - - if rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]: - return SPEED_HIGH - - if rotation_speed > SPEED_TO_PCNT[SPEED_LOW]: - return SPEED_MEDIUM - - if rotation_speed > SPEED_TO_PCNT[SPEED_OFF]: - return SPEED_LOW - - return SPEED_OFF - - @property - def speed_list(self): - """Get the list of available speeds.""" - if self.supported_features & SUPPORT_SET_SPEED: - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - return [] + return self.service.value(CharacteristicsTypes.ROTATION_SPEED) @property def current_direction(self): @@ -115,13 +86,13 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): {CharacteristicsTypes.ROTATION_DIRECTION: DIRECTION_TO_HK[direction]} ) - async def async_set_speed(self, speed): + async def async_set_percentage(self, percentage): """Set the speed of the fan.""" - if speed == SPEED_OFF: + if percentage == 0: return await self.async_turn_off() await self.async_put_characteristics( - {CharacteristicsTypes.ROTATION_SPEED: SPEED_TO_PCNT[speed]} + {CharacteristicsTypes.ROTATION_SPEED: percentage} ) async def async_oscillate(self, oscillating: bool): @@ -130,16 +101,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): {CharacteristicsTypes.SWING_MODE: 1 if oscillating else 0} ) - async def async_turn_on(self, speed=None, **kwargs): + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): """Turn the specified fan on.""" - characteristics = {} if not self.is_on: characteristics[self.on_characteristic] = True - if self.supported_features & SUPPORT_SET_SPEED and speed: - characteristics[CharacteristicsTypes.ROTATION_SPEED] = SPEED_TO_PCNT[speed] + if self.supported_features & SUPPORT_SET_SPEED: + characteristics[CharacteristicsTypes.ROTATION_SPEED] = percentage if characteristics: await self.async_put_characteristics(characteristics) diff --git a/homeassistant/components/homekit_controller/translations/de.json b/homeassistant/components/homekit_controller/translations/de.json index 7bab8f30574..49586a23634 100644 --- a/homeassistant/components/homekit_controller/translations/de.json +++ b/homeassistant/components/homekit_controller/translations/de.json @@ -18,6 +18,12 @@ }, "flow_title": "HomeKit-Zubeh\u00f6r: {name}", "step": { + "busy_error": { + "title": "Das Ger\u00e4t wird bereits mit einem anderen Controller gekoppelt" + }, + "max_tries_error": { + "title": "Maximale Authentifizierungsversuche \u00fcberschritten" + }, "pair": { "data": { "pairing_code": "Kopplungscode" @@ -25,6 +31,9 @@ "description": "Gib deinen HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", "title": "Mit HomeKit Zubeh\u00f6r koppeln" }, + "protocol_error": { + "title": "Fehler bei der Kommunikation mit dem Zubeh\u00f6r" + }, "user": { "data": { "device": "Ger\u00e4t" diff --git a/homeassistant/components/homekit_controller/translations/et.json b/homeassistant/components/homekit_controller/translations/et.json index 40537554ee2..6df49751478 100644 --- a/homeassistant/components/homekit_controller/translations/et.json +++ b/homeassistant/components/homekit_controller/translations/et.json @@ -13,7 +13,7 @@ "error": { "authentication_error": "Vale HomeKiti kood. Kontrolli seda ja proovi uuesti.", "max_peers_error": "Seade keeldus sidumist lisamast kuna puudub piisav salvestusruum.", - "pairing_failed": "Selle seadmega sidumise katsel ilmnes tundmatu t\u00f5rge. See v\u00f5ib olla ajutine t\u00f5rge v\u00f5i tseadet ei toetata praegu.", + "pairing_failed": "Selle seadmega sidumise katsel ilmnes tundmatu t\u00f5rge. See v\u00f5ib olla ajutine t\u00f5rge v\u00f5i seadet ei toetata praegu.", "unable_to_pair": "Ei saa siduda, proovi uuesti.", "unknown_error": "Seade teatas tundmatust t\u00f5rkest. Sidumine nurjus." }, diff --git a/homeassistant/components/homekit_controller/translations/ko.json b/homeassistant/components/homekit_controller/translations/ko.json index 2c41447b5a0..7314f43545e 100644 --- a/homeassistant/components/homekit_controller/translations/ko.json +++ b/homeassistant/components/homekit_controller/translations/ko.json @@ -3,7 +3,7 @@ "abort": { "accessory_not_found_error": "\uae30\uae30\ub97c \ub354 \uc774\uc0c1 \ucc3e\uc744 \uc218 \uc5c6\uc73c\ubbc0\ub85c \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "invalid_config_entry": "\uc774 \uae30\uae30\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", @@ -17,7 +17,7 @@ "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4." }, - "flow_title": "HomeKit \uc561\uc138\uc11c\ub9ac: {name}", + "flow_title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud504\ub85c\ud1a0\ucf5c\uc744 \ud1b5\ud55c {name}", "step": { "busy_error": { "description": "\ubaa8\ub4e0 \ucee8\ud2b8\ub864\ub7ec\uc5d0\uc11c \ud398\uc5b4\ub9c1\uc744 \uc911\ub2e8\ud558\uac70\ub098 \uae30\uae30\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud55c \ub2e4\uc74c \ud398\uc5b4\ub9c1\uc744 \uacc4\uc18d\ud574\uc8fc\uc138\uc694.", @@ -31,8 +31,8 @@ "data": { "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc" }, - "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc (XXX-XX-XXX \ud615\uc2dd) \ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1\ud558\uae30" + "description": "HomeKit \ucee8\ud2b8\ub864\ub7ec\ub294 \ubcc4\ub3c4\uc758 HomeKit \ucee8\ud2b8\ub864\ub7ec \ub610\ub294 iCloud \uc5c6\uc774 \uc554\ud638\ud654\ub41c \ubcf4\uc548 \uc5f0\uacb0\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub85c\uceec \uc601\uc5ed \ub124\ud2b8\uc6cc\ud06c \uc0c1\uc5d0\uc11c {name} \uacfc(\uc640) \ud1b5\uc2e0\ud569\ub2c8\ub2e4. \uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc (XX-XX-XXX \ud615\uc2dd) \uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. \uc774 \ucf54\ub4dc\ub294 \uc77c\ubc18\uc801\uc73c\ub85c \uae30\uae30\ub098 \ud3ec\uc7a5 \ubc15\uc2a4\uc5d0 \ud45c\uc2dc\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud504\ub85c\ud1a0\ucf5c\uc744 \ud1b5\ud574 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1 \ud558\uae30" }, "protocol_error": { "description": "\uae30\uae30\uac00 \ud398\uc5b4\ub9c1 \ubaa8\ub4dc\uc5d0 \uc788\uc9c0 \uc54a\uc744 \uc218 \uc788\uc73c\uba70 \ubb3c\ub9ac\uc801 \ub610\ub294 \uac00\uc0c1 \uc758 \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc57c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uae30\uae30\uac00 \ud398\uc5b4\ub9c1 \ubaa8\ub4dc\uc5d0 \uc788\ub294\uc9c0 \ud655\uc778\ud558\uac70\ub098 \uae30\uae30\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud55c \ub2e4\uc74c \ud398\uc5b4\ub9c1\uc744 \uacc4\uc18d\ud574\uc8fc\uc138\uc694.", @@ -42,8 +42,8 @@ "data": { "device": "\uae30\uae30" }, - "description": "\ud398\uc5b4\ub9c1 \ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", - "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1\ud558\uae30" + "description": "HomeKit \ucee8\ud2b8\ub864\ub7ec\ub294 \ubcc4\ub3c4\uc758 HomeKit \ucee8\ud2b8\ub864\ub7ec \ub610\ub294 iCloud \uc5c6\uc774 \uc554\ud638\ud654\ub41c \ubcf4\uc548 \uc5f0\uacb0\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub85c\uceec \uc601\uc5ed \ub124\ud2b8\uc6cc\ud06c \uc0c1\uc5d0\uc11c \uae30\uae30\uc640 \ud1b5\uc2e0\ud569\ub2c8\ub2e4. \ud398\uc5b4\ub9c1 \ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:", + "title": "\uae30\uae30 \uc120\ud0dd\ud558\uae30" } } }, diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 1f727bab4e1..6df738037bf 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -10,10 +10,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_NAME, + ATTR_TIME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, + CONF_PATH, CONF_PLATFORM, + CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -37,7 +40,6 @@ from .const import ( ATTR_PARAMSET, ATTR_PARAMSET_KEY, ATTR_RX_MODE, - ATTR_TIME, ATTR_UNIQUE_ID, ATTR_VALUE, ATTR_VALUE_TYPE, @@ -47,8 +49,6 @@ from .const import ( CONF_JSONPORT, CONF_LOCAL_IP, CONF_LOCAL_PORT, - CONF_PATH, - CONF_PORT, CONF_RESOLVENAMES, CONF_RESOLVENAMES_OPTIONS, DATA_CONF, @@ -209,7 +209,6 @@ SCHEMA_SERVICE_PUT_PARAMSET = vol.Schema( def setup(hass, config): """Set up the Homematic component.""" - conf = config[DOMAIN] hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index a6ff19a6eea..e8fa272b0e5 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -21,7 +21,6 @@ ATTR_VALUE_TYPE = "value_type" ATTR_INTERFACE = "interface" ATTR_ERRORCODE = "error" ATTR_MESSAGE = "message" -ATTR_TIME = "time" ATTR_UNIQUE_ID = "unique_id" ATTR_PARAMSET_KEY = "paramset_key" ATTR_PARAMSET = "paramset" @@ -232,8 +231,6 @@ DATA_CONF = "homematic_conf" CONF_INTERFACES = "interfaces" CONF_LOCAL_IP = "local_ip" CONF_LOCAL_PORT = "local_port" -CONF_PORT = "port" -CONF_PATH = "path" CONF_CALLBACK_IP = "callback_ip" CONF_CALLBACK_PORT = "callback_port" CONF_RESOLVENAMES = "resolvenames" diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index a391fa80461..bb87d691fc0 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -73,7 +73,6 @@ class HMDevice(Entity): @property def device_state_attributes(self): """Return device specific state attributes.""" - # Static attributes attr = { "id": self._hmdevice.ADDRESS, diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 7ea6a4fe0b4..ca1af8266c6 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,6 +1,4 @@ """Support for HomematicIP Cloud devices.""" -import logging - import voluptuous as vol from homeassistant import config_entries @@ -23,8 +21,6 @@ from .generic_entity import HomematicipGenericEntity # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 from .services import async_setup_services, async_unload_services -_LOGGER = logging.getLogger(__name__) - CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=[]): vol.All( diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index a8df0107eeb..a1e13658d20 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Optional from homematicip.aio.device import AsyncDevice from homematicip.aio.group import AsyncGroup +from homeassistant.const import ATTR_ID from homeassistant.core import callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import Entity @@ -19,7 +20,6 @@ ATTR_LOW_BATTERY = "low_battery" ATTR_CONFIG_PENDING = "config_pending" ATTR_CONNECTION_TYPE = "connection_type" ATTR_DUTY_CYCLE_REACHED = "duty_cycle_reached" -ATTR_ID = "id" ATTR_IS_GROUP = "is_group" # RSSI HAP -> Device ATTR_RSSI_DEVICE = "rssi_device" @@ -172,7 +172,7 @@ class HomematicipGenericEntity(Entity): """Handle hmip device removal.""" # Set marker showing that the HmIP device hase been removed. self.hmip_device_removed = True - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @property def name(self) -> str: diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index d8535edda50..7c92ac5e721 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -9,7 +9,7 @@ from homematicip.aio.home import AsyncHome from homematicip.base.helpers import handle_config import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids from homeassistant.helpers.service import ( @@ -29,7 +29,6 @@ ATTR_CONFIG_OUTPUT_FILE_PREFIX = "config_output_file_prefix" ATTR_CONFIG_OUTPUT_PATH = "config_output_path" ATTR_DURATION = "duration" ATTR_ENDTIME = "endtime" -ATTR_TEMPERATURE = "temperature" DEFAULT_CONFIG_FILE_PREFIX = "hmip-config" diff --git a/homeassistant/components/homematicip_cloud/translations/ko.json b/homeassistant/components/homematicip_cloud/translations/ko.json index b85b8ac00b1..6a15b21de84 100644 --- a/homeassistant/components/homematicip_cloud/translations/ko.json +++ b/homeassistant/components/homematicip_cloud/translations/ko.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "connection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "connection_aborted": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "invalid_sgtin_or_pin": "PIN\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_sgtin_or_pin": "\uc798\ubabb\ub41c SGTIN \uc774\uac70\ub098 PIN \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "press_the_button": "\ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", "register_failed": "\ub4f1\ub85d\uc5d0 \uc2e4\ud328\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "timeout_button": "\uc815\ud574\uc9c4 \uc2dc\uac04\ub0b4\uc5d0 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub974\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." @@ -16,7 +16,7 @@ "data": { "hapid": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 ID (SGTIN)", "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d, \ubaa8\ub4e0 \uae30\uae30 \uc774\ub984\uc758 \uc811\ub450\uc5b4\ub85c \uc0ac\uc6a9)", - "pin": "PIN \ucf54\ub4dc (\uc120\ud0dd\uc0ac\ud56d)" + "pin": "PIN \ucf54\ub4dc" }, "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd\ud558\uae30" }, diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index c07cddb7a9c..33dd8118ee4 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -26,6 +26,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.const import ( + ATTR_NAME, HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, @@ -73,7 +74,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ATTR_SUBSCRIPTION = "subscription" ATTR_BROWSER = "browser" -ATTR_NAME = "name" ATTR_ENDPOINT = "endpoint" ATTR_KEYS = "keys" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 7f70d49f686..993d466ae18 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -4,7 +4,6 @@ from ipaddress import ip_network import logging import os import ssl -from traceback import extract_stack from typing import Dict, Optional, cast from aiohttp import web @@ -58,8 +57,9 @@ SSL_INTERMEDIATE = "intermediate" _LOGGER = logging.getLogger(__name__) DEFAULT_DEVELOPMENT = "0" -# To be able to load custom cards. -DEFAULT_CORS = "https://cast.home-assistant.io" +# Cast to be able to load custom cards. +# My to be able to check url and version info. +DEFAULT_CORS = ["https://cast.home-assistant.io"] NO_LOGIN_ATTEMPT_THRESHOLD = -1 MAX_CLIENT_SIZE: int = 1024 ** 2 * 16 @@ -80,7 +80,7 @@ HTTP_SCHEMA = vol.All( vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_KEY): cv.isfile, - vol.Optional(CONF_CORS_ORIGINS, default=[DEFAULT_CORS]): vol.All( + vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All( cv.ensure_list, [cv.string] ), vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, @@ -124,69 +124,6 @@ class ApiConfig: self.port = port self.use_ssl = use_ssl - host = host.rstrip("/") - if host.startswith(("http://", "https://")): - self.deprecated_base_url = host - elif use_ssl: - self.deprecated_base_url = f"https://{host}" - else: - self.deprecated_base_url = f"http://{host}" - - if port is not None: - self.deprecated_base_url += f":{port}" - - @property - def base_url(self) -> str: - """Proxy property to find caller of this deprecated property.""" - found_frame = None - for frame in reversed(extract_stack()[:-1]): - for path in ("custom_components/", "homeassistant/components/"): - try: - index = frame.filename.index(path) - - # Skip webhook from the stack - if frame.filename[index:].startswith( - "homeassistant/components/webhook/" - ): - continue - - found_frame = frame - break - except ValueError: - continue - - if found_frame is not None: - break - - # Did not source from an integration? Hard error. - if found_frame is None: - raise RuntimeError( - "Detected use of deprecated `base_url` property in the Home Assistant core. Please report this issue." - ) - - # If a frame was found, it originated from an integration - if found_frame: - start = index + len(path) - end = found_frame.filename.index("/", start) - - integration = found_frame.filename[start:end] - - if path == "custom_components/": - extra = " to the custom component author" - else: - extra = "" - - _LOGGER.warning( - "Detected use of deprecated `base_url` property, use `homeassistant.helpers.network.get_url` method instead. Please report issue%s for %s using this method at %s, line %s: %s", - extra, - integration, - found_frame.filename[index:], - found_frame.lineno, - found_frame.line.strip(), - ) - - return self.deprecated_base_url - async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" @@ -255,20 +192,16 @@ async def async_setup(hass, config): hass.http = server - host = conf.get(CONF_BASE_URL) local_ip = await hass.async_add_executor_job(hass_util.get_local_ip) - if host: - port = None - elif server_host is not None: + host = local_ip + if server_host is not None: # Assume the first server host name provided as API host host = server_host[0] - port = server_port - else: - host = local_ip - port = server_port - hass.config.api = ApiConfig(local_ip, host, port, ssl_certificate is not None) + hass.config.api = ApiConfig( + local_ip, host, server_port, ssl_certificate is not None + ) return True diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index f9e6df94489..3267c9cc70e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -9,7 +9,7 @@ import jwt from homeassistant.core import callback from homeassistant.util import dt as dt_util -from .const import KEY_AUTHENTICATED, KEY_HASS_USER +from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # mypy: allow-untyped-defs, no-check-untyped-defs @@ -62,6 +62,7 @@ def setup_auth(hass, app): return False request[KEY_HASS_USER] = refresh_token.user + request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True async def async_validate_signed_request(request): @@ -92,6 +93,7 @@ def setup_auth(hass, app): return False request[KEY_HASS_USER] = refresh_token.user + request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True @middleware diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index ebbc6cb9b81..3a32635bb27 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -2,3 +2,4 @@ KEY_AUTHENTICATED = "ha_authenticated" KEY_HASS = "hass" KEY_HASS_USER = "hass_user" +KEY_HASS_REFRESH_TOKEN_ID = "hass_refresh_token_id" diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index ba8baedcaf7..e38b873a5bb 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -1,4 +1,5 @@ """Config flow for the Huawei LTE platform.""" +from __future__ import annotations from collections import OrderedDict import logging @@ -48,7 +49,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, - ) -> "OptionsFlowHandler": + ) -> OptionsFlowHandler: """Get options flow.""" return OptionsFlowHandler(config_entry) @@ -69,10 +70,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_URL, default=user_input.get( CONF_URL, - # https://github.com/PyCQA/pylint/issues/3167 - self.context.get( # pylint: disable=no-member - CONF_URL, "" - ), + self.context.get(CONF_URL, ""), ), ), str, @@ -191,7 +189,6 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): title = info.get("DeviceName") return title or DEFAULT_DEVICE_NAME - assert self.hass is not None try: conn = await self.hass.async_add_executor_job(try_connect, user_input) except LoginErrorUsernameWrongException: @@ -217,7 +214,6 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input=user_input, errors=errors ) - # pylint: disable=no-member title = self.context.get("title_placeholders", {}).get( CONF_NAME ) or await self.hass.async_add_executor_job(get_router_title, conn) @@ -237,8 +233,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): return self.async_abort(reason="not_huawei_lte") - # https://github.com/PyCQA/pylint/issues/3167 - url = self.context[CONF_URL] = url_normalize( # pylint: disable=no-member + url = self.context[CONF_URL] = url_normalize( discovery_info.get( ssdp.ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", @@ -254,7 +249,6 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if self._already_configured(user_input): return self.async_abort(reason="already_configured") - # pylint: disable=no-member self.context["title_placeholders"] = { CONF_NAME: discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) } diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 5659e66ea98..ef354fefaf3 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,4 +1,5 @@ """Support for Huawei LTE router notifications.""" +from __future__ import annotations import logging import time @@ -21,7 +22,7 @@ async def async_get_service( hass: HomeAssistantType, config: Dict[str, Any], discovery_info: Optional[Dict[str, Any]] = None, -) -> Optional["HuaweiLteSmsNotificationService"]: +) -> Optional[HuaweiLteSmsNotificationService]: """Get the notification service.""" if discovery_info is None: return None diff --git a/homeassistant/components/huawei_lte/translations/ko.json b/homeassistant/components/huawei_lte/translations/ko.json index 6469bf6a696..73274d15bfb 100644 --- a/homeassistant/components/huawei_lte/translations/ko.json +++ b/homeassistant/components/huawei_lte/translations/ko.json @@ -2,22 +2,25 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "not_huawei_lte": "\ud654\uc6e8\uc774 LTE \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" }, "error": { "connection_timeout": "\uc811\uc18d \uc2dc\uac04 \ucd08\uacfc", "incorrect_password": "\ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "incorrect_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_url": "URL \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "login_attempts_exceeded": "\ucd5c\ub300 \ub85c\uadf8\uc778 \uc2dc\ub3c4 \ud69f\uc218\ub97c \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", - "response_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "response_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Huawei LTE: {name}", "step": { "user": { "data": { "password": "\ube44\ubc00\ubc88\ud638", + "url": "URL \uc8fc\uc18c", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, "description": "\uae30\uae30 \uc561\uc138\uc2a4 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc124\uc815\ud558\ub294 \uac83\uc740 \uc120\ud0dd \uc0ac\ud56d\uc774\uc9c0\ub9cc \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubc18\uba74, \uc778\uc99d\ub41c \uc5f0\uacb0\uc744 \uc0ac\uc6a9\ud558\uba74, \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \ud65c\uc131\ud654\ub41c \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c Home Assistant \uc758 \uc678\ubd80\uc5d0\uc11c \uae30\uae30\uc758 \uc6f9 \uc778\ud130\ud398\uc774\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud558\ub294 \ub370 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index dd51fdc1bc5..d420093996c 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -19,6 +19,7 @@ "user": { "data": { "password": "Wachtwoord", + "url": "URL", "username": "Gebruikersnaam" }, "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", diff --git a/homeassistant/components/huawei_lte/translations/ru.json b/homeassistant/components/huawei_lte/translations/ru.json index 88457457796..c2ec20fb259 100644 --- a/homeassistant/components/huawei_lte/translations/ru.json +++ b/homeassistant/components/huawei_lte/translations/ru.json @@ -9,7 +9,7 @@ "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", "incorrect_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c.", "incorrect_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.", "login_attempts_exceeded": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0432\u0445\u043e\u0434\u0430, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 880d9abcc35..201e9f3a546 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -19,6 +19,7 @@ from .const import ( CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, + DEFAULT_SCENE_TRANSITION, LOGGER, ) from .errors import AuthenticationRequired, CannotConnect @@ -28,8 +29,15 @@ from .sensor_base import SensorManager SERVICE_HUE_SCENE = "hue_activate_scene" ATTR_GROUP_NAME = "group_name" ATTR_SCENE_NAME = "scene_name" +ATTR_TRANSITION = "transition" SCENE_SCHEMA = vol.Schema( - {vol.Required(ATTR_GROUP_NAME): cv.string, vol.Required(ATTR_SCENE_NAME): cv.string} + { + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, + vol.Optional( + ATTR_TRANSITION, default=DEFAULT_SCENE_TRANSITION + ): cv.positive_int, + } ) # How long should we sleep if the hub is busy HUB_BUSY_SLEEP = 0.5 @@ -201,6 +209,7 @@ class HueBridge: """Service to call directly into bridge to set scenes.""" group_name = call.data[ATTR_GROUP_NAME] scene_name = call.data[ATTR_SCENE_NAME] + transition = call.data.get(ATTR_TRANSITION, DEFAULT_SCENE_TRANSITION) group = next( (group for group in self.api.groups.values() if group.name == group_name), @@ -236,7 +245,9 @@ class HueBridge: LOGGER.warning("Unable to find scene %s", scene_name) return False - return await self.async_request_call(partial(group.set_action, scene=scene.id)) + return await self.async_request_call( + partial(group.set_action, scene=scene.id, transitiontime=transition) + ) async def handle_unauthorized_error(self): """Create a new config flow when the authorization is no longer valid.""" diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index ecb3fd8c489..580b69251c2 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -18,6 +18,8 @@ from .bridge import authenticate_bridge from .const import ( # pylint: disable=unused-import CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_HUE_GROUPS, + DEFAULT_ALLOW_UNREACHABLE, DOMAIN, LOGGER, ) @@ -246,13 +248,13 @@ class HueOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_ALLOW_HUE_GROUPS, default=self.config_entry.options.get( - CONF_ALLOW_HUE_GROUPS, False + CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS ), ): bool, vol.Optional( CONF_ALLOW_UNREACHABLE, default=self.config_entry.options.get( - CONF_ALLOW_UNREACHABLE, False + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE ), ): bool, } diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index e2189515482..b782ce70193 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -12,4 +12,11 @@ CONF_ALLOW_UNREACHABLE = "allow_unreachable" DEFAULT_ALLOW_UNREACHABLE = False CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" -DEFAULT_ALLOW_HUE_GROUPS = True +DEFAULT_ALLOW_HUE_GROUPS = False + +DEFAULT_SCENE_TRANSITION = 4 + +GROUP_TYPE_LIGHT_GROUP = "LightGroup" +GROUP_TYPE_ROOM = "Room" +GROUP_TYPE_LUMINAIRE = "Luminaire" +GROUP_TYPE_LIGHT_SOURCE = "LightSource" diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/helpers.py index 1760c59a69d..739e27d3360 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/helpers.py @@ -17,7 +17,7 @@ async def remove_devices(bridge, api_ids, current): # Device is removed from Hue, so we remove it from Home Assistant entity = current[item_id] removed_items.append(item_id) - await entity.async_remove() + await entity.async_remove(force_remove=True) ent_registry = await get_ent_reg(bridge.hass) if entity.entity_id in ent_registry.entities: ent_registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 821d482ec25..6384e47b45e 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -36,7 +36,14 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import color -from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY +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) @@ -74,24 +81,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """ -def create_light(item_class, coordinator, bridge, is_group, api, item_id): +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_id].lights: + 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_id].type, SUPPORT_HUE_EXTENDED) - return item_class(coordinator, bridge, is_group, api[item_id], supported_features) + 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, @@ -111,27 +129,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if not light_coordinator.last_update_success: raise PlatformNotReady - update_lights = partial( - async_update_items, - bridge, - bridge.api.lights, - {}, - async_add_entities, - partial(create_light, HueLight, light_coordinator, bridge, False), - ) - - # We add a listener after fetching the data, so manually trigger listener - bridge.reset_jobs.append(light_coordinator.async_add_listener(update_lights)) - update_lights() - - api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) - - allow_groups = bridge.allow_groups - if allow_groups and api_version < GROUP_MIN_API_VERSION: - _LOGGER.warning("Please update your Hue bridge to support groups") - allow_groups = False - - if not allow_groups: + 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( @@ -145,17 +156,69 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ), ) - update_groups = partial( + 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.groups, + bridge.api.lights, {}, async_add_entities, - partial(create_light, HueLight, group_coordinator, bridge, True), + partial(create_light, HueLight, light_coordinator, bridge, False, rooms), + _setup_rooms_listener, ) - - bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups)) - await group_coordinator.async_refresh() + # 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): @@ -171,7 +234,9 @@ async def async_safe_fetch(bridge, fetch_method): @callback -def async_update_items(bridge, api, current, async_add_entities, create_item): +def async_update_items( + bridge, api, current, async_add_entities, create_item, new_items_callback +): """Update items.""" new_items = [] @@ -185,6 +250,9 @@ def async_update_items(bridge, api, current, async_add_entities, create_item): 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) @@ -201,13 +269,14 @@ def hass_to_hue_brightness(value): class HueLight(CoordinatorEntity, LightEntity): """Representation of a Hue light.""" - def __init__(self, coordinator, bridge, is_group, light, supported_features): + 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 @@ -355,10 +424,15 @@ class HueLight(CoordinatorEntity, LightEntity): @property def device_info(self): """Return the device info.""" - if self.light.type in ("LightGroup", "Room", "Luminaire", "LightSource"): + if self.light.type in ( + GROUP_TYPE_LIGHT_GROUP, + GROUP_TYPE_ROOM, + GROUP_TYPE_LUMINAIRE, + GROUP_TYPE_LIGHT_SOURCE, + ): return None - return { + info = { "identifiers": {(HUE_DOMAIN, self.device_id)}, "name": self.name, "manufacturer": self.light.manufacturername, @@ -370,6 +444,11 @@ class HueLight(CoordinatorEntity, LightEntity): "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } + if self.light.id in self._rooms: + info["suggested_area"] = self._rooms[self.light.id] + + return info + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {"on": True} diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index 68eaf6ac377..07eeca6fa0f 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -1,11 +1,18 @@ # Describes the format for available hue services hue_activate_scene: + name: Activate scene description: Activate a hue scene stored in the hue hub. fields: group_name: + name: Group description: Name of hue group/room from the hue app. example: "Living Room" + selector: + text: scene_name: + name: Scene description: Name of hue scene from the hue app. example: "Energize" + selector: + text: diff --git a/homeassistant/components/hue/translations/et.json b/homeassistant/components/hue/translations/et.json index fcec56b9d0b..afde880690e 100644 --- a/homeassistant/components/hue/translations/et.json +++ b/homeassistant/components/hue/translations/et.json @@ -12,7 +12,7 @@ }, "error": { "linking": "Ilmnes tundmatu linkimist\u00f5rge.", - "register_failed": "Registreerimine nurjus. Proovige uuesti" + "register_failed": "Registreerimine nurjus. Proovi uuesti" }, "step": { "init": { @@ -22,7 +22,7 @@ "title": "Vali Hue sild" }, "link": { - "description": "Vajutage silla nuppu, et registreerida Philips Hue Home Assistant abil. \n\n ! [Nupu asukoht sillal] (/ static / images / config_philips_hue.jpg)", + "description": "Vajuta silla nuppu, et registreerida Philips Hue Home Assistant abil. \n\n ! [Nupu asukoht sillal] (/ static / images / config_philips_hue.jpg)", "title": "\u00dchenda jaotusseade" }, "manual": { diff --git a/homeassistant/components/hue/translations/ko.json b/homeassistant/components/hue/translations/ko.json index 050b5c51c97..846ea937515 100644 --- a/homeassistant/components/hue/translations/ko.json +++ b/homeassistant/components/hue/translations/ko.json @@ -2,16 +2,16 @@ "config": { "abort": { "all_configured": "\ubaa8\ub4e0 \ud544\ub9bd\uc2a4 Hue \ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_configured": "\ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\ube0c\ub9ac\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", - "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "discover_timeout": "Hue \ube0c\ub9ac\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9ac\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", "not_hue_bridge": "Hue \ube0c\ub9ac\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "linking": "\uc54c \uc218 \uc5c6\ub294 \uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "linking": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" }, "step": { diff --git a/homeassistant/components/hue/translations/nl.json b/homeassistant/components/hue/translations/nl.json index f04d372bf6a..cead9dd21c6 100644 --- a/homeassistant/components/hue/translations/nl.json +++ b/homeassistant/components/hue/translations/nl.json @@ -8,7 +8,7 @@ "discover_timeout": "Hue bridges kunnen niet worden gevonden", "no_bridges": "Geen Philips Hue bridges ontdekt", "not_hue_bridge": "Dit is geen Hue bridge", - "unknown": "Onbekende fout opgetreden" + "unknown": "Onverwachte fout" }, "error": { "linking": "Er is een onbekende verbindingsfout opgetreden.", diff --git a/homeassistant/components/huisbaasje/translations/de.json b/homeassistant/components/huisbaasje/translations/de.json new file mode 100644 index 00000000000..ca3f90536d4 --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "connection_exception": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unauthenticated_exception": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/es.json b/homeassistant/components/huisbaasje/translations/es.json new file mode 100644 index 00000000000..def06b0941d --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "connection_exception": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unauthenticated_exception": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/fr.json b/homeassistant/components/huisbaasje/translations/fr.json new file mode 100644 index 00000000000..9f78d7d8826 --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + }, + "error": { + "connection_exception": "\u00c9chec de la connexion ", + "invalid_auth": "Authentification invalide ", + "unauthenticated_exception": "Authentification invalide ", + "unknown": "Erreur inatendue" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/ko.json b/homeassistant/components/huisbaasje/translations/ko.json new file mode 100644 index 00000000000..bd25569d7c7 --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "connection_exception": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unauthenticated_exception": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/nl.json b/homeassistant/components/huisbaasje/translations/nl.json new file mode 100644 index 00000000000..8cb09793af8 --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "connection_exception": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unauthenticated_exception": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/ru.json b/homeassistant/components/huisbaasje/translations/ru.json index ada9aed539a..a598320115d 100644 --- a/homeassistant/components/huisbaasje/translations/ru.json +++ b/homeassistant/components/huisbaasje/translations/ru.json @@ -5,8 +5,8 @@ }, "error": { "connection_exception": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", - "unauthenticated_exception": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unauthenticated_exception": "\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": { diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index fc455feb477..1763e169d50 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_MODE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -27,7 +28,6 @@ from .const import ( ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, - ATTR_MODE, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, DEVICE_CLASS_DEHUMIDIFIER, diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 82e87ae5c31..7e70c51df28 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,4 +1,5 @@ """Provides the constants needed for component.""" +from homeassistant.const import ATTR_MODE # noqa: F401 pylint: disable=unused-import MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -10,7 +11,6 @@ MODE_SLEEP = "sleep" MODE_AUTO = "auto" MODE_BABY = "baby" -ATTR_MODE = "mode" ATTR_AVAILABLE_MODES = "available_modes" ATTR_HUMIDITY = "humidity" ATTR_MAX_HUMIDITY = "max_humidity" diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index d96beec53ae..e90b315fd16 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -77,9 +77,11 @@ async def async_setup_entry(hass, entry, async_add_entities): name_before_refresh, ) continue + room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) + room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.append( PowerViewShade( - shade, name_before_refresh, room_data, coordinator, device_info + coordinator, device_info, room_name, shade, name_before_refresh ) ) async_add_entities(entities) @@ -98,17 +100,14 @@ def hass_position_to_hd(hass_positon): class PowerViewShade(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" - def __init__(self, shade, name, room_data, coordinator, device_info): + def __init__(self, coordinator, device_info, room_name, shade, name): """Initialize the shade.""" - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - super().__init__(coordinator, device_info, shade, name) + super().__init__(coordinator, device_info, room_name, shade, name) self._shade = shade - self._device_info = device_info self._is_opening = False self._is_closing = False self._last_action_timestamp = 0 self._scheduled_transition_update = None - self._room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") self._current_cover_position = MIN_POSITION @property diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 4ed68fc3557..679e55e806c 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -23,9 +23,10 @@ from .const import ( class HDEntity(CoordinatorEntity): """Base class for hunter douglas entities.""" - def __init__(self, coordinator, device_info, unique_id): + def __init__(self, coordinator, device_info, room_name, unique_id): """Initialize the entity.""" super().__init__(coordinator) + self._room_name = room_name self._unique_id = unique_id self._device_info = device_info @@ -45,6 +46,7 @@ class HDEntity(CoordinatorEntity): (dr.CONNECTION_NETWORK_MAC, self._device_info[DEVICE_MAC_ADDRESS]) }, "name": self._device_info[DEVICE_NAME], + "suggested_area": self._room_name, "model": self._device_info[DEVICE_MODEL], "sw_version": sw_version, "manufacturer": MANUFACTURER, @@ -54,9 +56,9 @@ class HDEntity(CoordinatorEntity): class ShadeEntity(HDEntity): """Base class for hunter douglas shade entities.""" - def __init__(self, coordinator, device_info, shade, shade_name): + def __init__(self, coordinator, device_info, room_name, shade, shade_name): """Initialize the shade.""" - super().__init__(coordinator, device_info, shade.id) + super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade @@ -67,6 +69,7 @@ class ShadeEntity(HDEntity): device_info = { "identifiers": {(DOMAIN, self._shade.id)}, "name": self._shade_name, + "suggested_area": self._room_name, "manufacturer": MANUFACTURER, "via_device": (DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), } diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 61c93078aa1..33c7e7129fc 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -49,23 +49,21 @@ async def async_setup_entry(hass, entry, async_add_entities): coordinator = pv_data[COORDINATOR] device_info = pv_data[DEVICE_INFO] - pvscenes = ( - PowerViewScene( - PvScene(raw_scene, pv_request), room_data, coordinator, device_info - ) - for scene_id, raw_scene in scene_data.items() - ) + pvscenes = [] + for raw_scene in scene_data.values(): + scene = PvScene(raw_scene, pv_request) + room_name = room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + pvscenes.append(PowerViewScene(coordinator, device_info, room_name, scene)) async_add_entities(pvscenes) class PowerViewScene(HDEntity, Scene): """Representation of a Powerview scene.""" - def __init__(self, scene, room_data, coordinator, device_info): + def __init__(self, coordinator, device_info, room_name, scene): """Initialize the scene.""" - super().__init__(coordinator, device_info, scene.id) + super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene - self._room_name = room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") @property def name(self): diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 6241ddd4d62..130e8dd507a 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -9,7 +9,10 @@ from .const import ( DEVICE_INFO, DOMAIN, PV_API, + PV_ROOM_DATA, PV_SHADE_DATA, + ROOM_ID_IN_SHADE, + ROOM_NAME_UNICODE, SHADE_BATTERY_LEVEL, SHADE_BATTERY_LEVEL_MAX, ) @@ -20,6 +23,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the hunter douglas shades sensors.""" pv_data = hass.data[DOMAIN][entry.entry_id] + room_data = pv_data[PV_ROOM_DATA] shade_data = pv_data[PV_SHADE_DATA] pv_request = pv_data[PV_API] coordinator = pv_data[COORDINATOR] @@ -31,9 +35,11 @@ async def async_setup_entry(hass, entry, async_add_entities): if SHADE_BATTERY_LEVEL not in shade.raw_data: continue name_before_refresh = shade.name + room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) + room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.append( PowerViewShadeBatterySensor( - coordinator, device_info, shade, name_before_refresh + coordinator, device_info, room_name, shade, name_before_refresh ) ) async_add_entities(entities) diff --git a/homeassistant/components/hunterdouglas_powerview/translations/ko.json b/homeassistant/components/hunterdouglas_powerview/translations/ko.json index cba1b761682..d16945084d0 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/ko.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/hvv_departures/translations/ko.json b/homeassistant/components/hvv_departures/translations/ko.json index 41c7f44be7f..ea6ef8bc23a 100644 --- a/homeassistant/components/hvv_departures/translations/ko.json +++ b/homeassistant/components/hvv_departures/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_results": "\uacb0\uacfc\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\ub978 \uc2a4\ud14c\uc774\uc158\uc774\ub098 \uc8fc\uc18c\ub97c \uc0ac\uc6a9\ud574\uc8fc\uc138\uc694" }, diff --git a/homeassistant/components/hvv_departures/translations/ru.json b/homeassistant/components/hvv_departures/translations/ru.json index ff5819a562d..6ae27715033 100644 --- a/homeassistant/components/hvv_departures/translations/ru.json +++ b/homeassistant/components/hvv_departures/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "no_results": "\u041d\u0435\u0442 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u043e\u0432. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0435\u0439 / \u0430\u0434\u0440\u0435\u0441\u043e\u043c." }, "step": { diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index aeac922826d..9e35ae2e6b8 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -4,8 +4,8 @@ import asyncio import logging from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast +from awesomeversion import AwesomeVersion from hyperion import client, const as hyperion_const -from pkg_resources import parse_version from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -159,7 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b version = await hyperion_client.async_sysinfo_version() if version is not None: try: - if parse_version(version) < parse_version(HYPERION_VERSION_WARN_CUTOFF): + if AwesomeVersion(version) < AwesomeVersion(HYPERION_VERSION_WARN_CUTOFF): _LOGGER.warning( "Using a Hyperion server version < %s is not recommended -- " "some features may be unavailable or may not function correctly. " @@ -278,10 +278,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b } ) - # Must only listen for option updates after the setup is complete, as otherwise - # the YAML->ConfigEntry migration code triggers an options update, which causes a - # reload -- which clashes with the initial load (causing entity_id / unique_id - # clashes). async def setup_then_listen() -> None: await asyncio.gather( *[ diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 11ab3289d14..642bc0e93fd 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -140,14 +140,6 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_auth() return await self.async_step_confirm() - async def async_step_import(self, import_data: ConfigType) -> Dict[str, Any]: - """Handle a flow initiated by a YAML config import.""" - self._data.update(import_data) - async with self._create_client(raw_connection=True) as hyperion_client: - if not hyperion_client: - return self.async_abort(reason="cannot_connect") - return await self._advance_to_auth_step_if_necessary(hyperion_client) - async def async_step_reauth( self, config_data: ConfigType, @@ -278,7 +270,6 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): auth_resp = await hyperion_client.async_request_token( comment=DEFAULT_ORIGIN, id=auth_id ) - assert self.hass await self.hass.config_entries.flow.async_configure( flow_id=self.flow_id, user_input=auth_resp ) @@ -352,7 +343,6 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): # Start a task in the background requesting a new token. The next step will # wait on the response (which includes the user needing to visit the Hyperion # UI to approve the request for a new token). - assert self.hass assert self._auth_id is not None self._request_token_task = self.hass.async_create_task( self._request_token_task_func(self._auth_id) @@ -422,9 +412,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): entry = await self.async_set_unique_id(hyperion_id, raise_on_progress=False) - # pylint: disable=no-member if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and entry is not None: - assert self.hass self.hass.config_entries.async_update_entry(entry, data=self._data) # Need to manually reload, as the listener won't have been installed because # the initial load did not succeed (the reauth flow will not be initiated if @@ -434,7 +422,6 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 return self.async_create_entry( title=f"{self._data[CONF_HOST]}:{self._data[CONF_PORT]}", data=self._data ) diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index a329ee5c20e..7bb8a75dfc7 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -1,48 +1,32 @@ """Support for Hyperion-NG remotes.""" from __future__ import annotations +import functools import logging -import re from types import MappingProxyType from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from hyperion import client, const -import voluptuous as vol -from homeassistant import data_entry_flow from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, - DOMAIN as LIGHT_DOMAIN, - PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_EFFECT, LightEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback -from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_registry import async_get_registry -from homeassistant.helpers.typing import ( - ConfigType, - DiscoveryInfoType, - HomeAssistantType, -) +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util -from . import ( - create_hyperion_client, - get_hyperion_unique_id, - listen_for_instance_updates, -) +from . import get_hyperion_unique_id, listen_for_instance_updates from .const import ( CONF_INSTANCE_CLIENTS, CONF_PRIORITY, @@ -73,8 +57,6 @@ CONF_EFFECT_LIST = "effect_list" # showing a solid color. This is the same method used by WLED. KEY_EFFECT_SOLID = "Solid" -KEY_ENTRY_ID_YAML = "YAML" - DEFAULT_COLOR = [255, 255, 255] DEFAULT_BRIGHTNESS = 255 DEFAULT_EFFECT = KEY_EFFECT_SOLID @@ -85,144 +67,11 @@ DEFAULT_EFFECT_LIST: List[str] = [] SUPPORT_HYPERION = SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT -# Usage of YAML for configuration of the Hyperion component is deprecated. -PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_HDMI_PRIORITY), - cv.deprecated(CONF_HOST), - cv.deprecated(CONF_PORT), - cv.deprecated(CONF_DEFAULT_COLOR), - cv.deprecated(CONF_NAME), - cv.deprecated(CONF_PRIORITY), - cv.deprecated(CONF_EFFECT_LIST), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): vol.All( - list, - vol.Length(min=3, max=3), - [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))], - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int, - vol.Optional( - CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY - ): cv.positive_int, - vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), -) - ICON_LIGHTBULB = "mdi:lightbulb" ICON_EFFECT = "mdi:lava-lamp" ICON_EXTERNAL_SOURCE = "mdi:television-ambient-light" -async def async_setup_platform( - hass: HomeAssistantType, - config: ConfigType, - async_add_entities: Callable, - discovery_info: Optional[DiscoveryInfoType] = None, -) -> None: - """Set up Hyperion platform..""" - - # This is the entrypoint for the old YAML-style Hyperion integration. The goal here - # is to auto-convert the YAML configuration into a config entry, with no human - # interaction, preserving the entity_id. This should be possible, as the YAML - # configuration did not support any of the things that should otherwise require - # human interaction in the config flow (e.g. it did not support auth). - - host = config[CONF_HOST] - port = config[CONF_PORT] - instance = 0 # YAML only supports a single instance. - - # First, connect to the server and get the server id (which will be unique_id on a config_entry - # if there is one). - async with create_hyperion_client(host, port) as hyperion_client: - if not hyperion_client: - raise PlatformNotReady - hyperion_id = await hyperion_client.async_sysinfo_id() - if not hyperion_id: - raise PlatformNotReady - - future_unique_id = get_hyperion_unique_id( - hyperion_id, instance, TYPE_HYPERION_LIGHT - ) - - # Possibility 1: Already converted. - # There is already a config entry with the unique id reporting by the - # server. Nothing to do here. - for entry in hass.config_entries.async_entries(domain=DOMAIN): - if entry.unique_id == hyperion_id: - return - - # Possibility 2: Upgraded to the new Hyperion component pre-config-flow. - # No config entry for this unique_id, but have an entity_registry entry - # with an old-style unique_id: - # :- (instance will always be 0, as YAML - # configuration does not support multiple - # instances) - # The unique_id needs to be updated, then the config_flow should do the rest. - registry = await async_get_registry(hass) - for entity_id, entity in registry.entities.items(): - if entity.config_entry_id is not None or entity.platform != DOMAIN: - continue - result = re.search(rf"([^:]+):(\d+)-{instance}", entity.unique_id) - if result and result.group(1) == host and int(result.group(2)) == port: - registry.async_update_entity(entity_id, new_unique_id=future_unique_id) - break - else: - # Possibility 3: This is the first upgrade to the new Hyperion component. - # No config entry and no entity_registry entry, in which case the CONF_NAME - # variable will be used as the preferred name. Rather than pollute the config - # entry with a "suggested name" type variable, instead create an entry in the - # registry that will subsequently be used when the entity is created with this - # unique_id. - - # This also covers the case that should not occur in the wild (no config entry, - # but new style unique_id). - registry.async_get_or_create( - domain=LIGHT_DOMAIN, - platform=DOMAIN, - unique_id=future_unique_id, - suggested_object_id=config[CONF_NAME], - ) - - async def migrate_yaml_to_config_entry_and_options( - host: str, port: int, priority: int - ) -> None: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: host, - CONF_PORT: port, - }, - ) - if ( - result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY - or result.get("result") is None - ): - _LOGGER.warning( - "Could not automatically migrate Hyperion YAML to a config entry." - ) - return - config_entry = result.get("result") - options = {**config_entry.options, CONF_PRIORITY: config[CONF_PRIORITY]} - hass.config_entries.async_update_entry(config_entry, options=options) - - _LOGGER.info( - "Successfully migrated Hyperion YAML configuration to a config entry." - ) - - # Kick off a config flow to create the config entry. - hass.async_create_task( - migrate_yaml_to_config_entry_and_options(host, port, config[CONF_PRIORITY]) - ) - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable ) -> bool: @@ -553,7 +402,7 @@ class HyperionBaseLight(LightEntity): async_dispatcher_connect( self.hass, SIGNAL_ENTITY_REMOVE.format(self._unique_id), - self.async_remove, + functools.partial(self.async_remove, force_remove=True), ) ) diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 372e9876c35..9d90e1e12ef 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -1,5 +1,6 @@ """Switch platform for Hyperion.""" +import functools from typing import Any, Callable, Dict, Optional from hyperion import client @@ -199,7 +200,7 @@ class HyperionComponentSwitch(SwitchEntity): async_dispatcher_connect( self.hass, SIGNAL_ENTITY_REMOVE.format(self._unique_id), - self.async_remove, + functools.partial(self.async_remove, force_remove=True), ) ) diff --git a/homeassistant/components/hyperion/translations/fr.json b/homeassistant/components/hyperion/translations/fr.json index 90733a8968b..f69fd6acdc6 100644 --- a/homeassistant/components/hyperion/translations/fr.json +++ b/homeassistant/components/hyperion/translations/fr.json @@ -1,10 +1,38 @@ { "config": { "abort": { + "already_configured": "Le service est d\u00e9ja configur\u00e9 ", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "auth_new_token_not_granted_error": "Le jeton nouvellement cr\u00e9\u00e9 n'a pas \u00e9t\u00e9 approuv\u00e9 sur l'interface utilisateur Hyperion", "auth_new_token_not_work_error": "\u00c9chec de l'authentification \u00e0 l'aide du jeton nouvellement cr\u00e9\u00e9", - "cannot_connect": "Echec de connection" + "auth_required_error": "Impossible de d\u00e9terminer si une autorisation est requise", + "cannot_connect": "Echec de connection", + "no_id": "L'instance Hyperion Ambilight n'a pas signal\u00e9 son identifiant", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "Echec de la connexion ", + "invalid_access_token": "jeton d'acc\u00e8s Invalide" }, "step": { + "auth": { + "data": { + "create_token": "Cr\u00e9er automatiquement un nouveau jeton", + "token": "Ou fournir un jeton pr\u00e9existant" + }, + "description": "Configurer l'autorisation sur votre serveur Hyperion Ambilight" + }, + "confirm": { + "description": "Voulez-vous ajouter cet Hyperion Ambilight \u00e0 Home Assistant? \n\n ** H\u00f4te: ** {host}\n ** Port: ** {port}\n ** ID **: {id}", + "title": "Confirmer l'ajout du service Hyperion Ambilight" + }, + "create_token": { + "description": "Choisissez ** Soumettre ** ci-dessous pour demander un nouveau jeton d'authentification. Vous serez redirig\u00e9 vers l'interface utilisateur Hyperion pour approuver la demande. Veuillez v\u00e9rifier que l'identifiant affich\u00e9 est \" {auth_id} \"", + "title": "Cr\u00e9er automatiquement un nouveau jeton d'authentification" + }, + "create_token_external": { + "title": "Accepter un nouveau jeton dans l'interface utilisateur Hyperion" + }, "user": { "data": { "host": "H\u00f4te", @@ -12,5 +40,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "priority": "Priorit\u00e9 Hyperion \u00e0 utiliser pour les couleurs et les effets" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/it.json b/homeassistant/components/hyperion/translations/it.json index 6fee49ebe14..b03b368d039 100644 --- a/homeassistant/components/hyperion/translations/it.json +++ b/homeassistant/components/hyperion/translations/it.json @@ -8,7 +8,7 @@ "auth_required_error": "Impossibile determinare se \u00e8 necessaria l'autorizzazione", "cannot_connect": "Impossibile connettersi", "no_id": "L'istanza Hyperion Ambilight non ha segnalato il suo ID", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/hyperion/translations/ko.json b/homeassistant/components/hyperion/translations/ko.json new file mode 100644 index 00000000000..295d418da12 --- /dev/null +++ b/homeassistant/components/hyperion/translations/ko.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/nl.json b/homeassistant/components/hyperion/translations/nl.json index d93018f8a3c..0898272e4a2 100644 --- a/homeassistant/components/hyperion/translations/nl.json +++ b/homeassistant/components/hyperion/translations/nl.json @@ -1,10 +1,32 @@ { "config": { + "abort": { + "already_configured": "Service is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "auth_new_token_not_granted_error": "Nieuw aangemaakte token is niet goedgekeurd in Hyperion UI", + "auth_new_token_not_work_error": "Verificatie met nieuw aangemaakt token mislukt", + "auth_required_error": "Kan niet bepalen of autorisatie vereist is", + "cannot_connect": "Kan geen verbinding maken", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_access_token": "Ongeldig toegangstoken" + }, "step": { "auth": { "data": { "create_token": "Maak automatisch een nieuw token aan" } + }, + "create_token_external": { + "title": "Accepteer nieuwe token in Hyperion UI" + }, + "user": { + "data": { + "host": "Host", + "port": "Poort" + } } } } diff --git a/homeassistant/components/hyperion/translations/zh-Hant.json b/homeassistant/components/hyperion/translations/zh-Hant.json index ed003131bf2..bb8eacd5376 100644 --- a/homeassistant/components/hyperion/translations/zh-Hant.json +++ b/homeassistant/components/hyperion/translations/zh-Hant.json @@ -3,8 +3,8 @@ "abort": { "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", - "auth_new_token_not_granted_error": "\u65b0\u5275\u5bc6\u9470\u672a\u7372\u5f97 Hyperion UI \u6838\u51c6", - "auth_new_token_not_work_error": "\u4f7f\u7528\u65b0\u5275\u5bc6\u9470\u8a8d\u8b49\u5931\u6557", + "auth_new_token_not_granted_error": "\u65b0\u5275\u6b0a\u6756\u672a\u7372\u5f97 Hyperion UI \u6838\u51c6", + "auth_new_token_not_work_error": "\u4f7f\u7528\u65b0\u5275\u6b0a\u6756\u8a8d\u8b49\u5931\u6557", "auth_required_error": "\u7121\u6cd5\u5224\u5b9a\u662f\u5426\u9700\u8981\u9a57\u8b49", "cannot_connect": "\u9023\u7dda\u5931\u6557", "no_id": "Hyperion Ambilight \u5be6\u9ad4\u672a\u56de\u5831\u5176 ID", @@ -12,13 +12,13 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_access_token": "\u5b58\u53d6\u5bc6\u9470\u7121\u6548" + "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548" }, "step": { "auth": { "data": { - "create_token": "\u81ea\u52d5\u65b0\u5275\u5bc6\u9470", - "token": "\u6216\u63d0\u4f9b\u73fe\u6709\u5bc6\u9470" + "create_token": "\u81ea\u52d5\u65b0\u5275\u6b0a\u6756", + "token": "\u6216\u63d0\u4f9b\u73fe\u6709\u6b0a\u6756" }, "description": "\u8a2d\u5b9a Hyperion Ambilight \u4f3a\u670d\u5668\u8a8d\u8b49" }, @@ -27,11 +27,11 @@ "title": "\u78ba\u8a8d\u9644\u52a0 Hyperion Ambilight \u670d\u52d9" }, "create_token": { - "description": "\u9ede\u9078\u4e0b\u65b9 **\u50b3\u9001** \u4ee5\u8acb\u6c42\u65b0\u8a8d\u8b49\u5bc6\u9470\u3002\u5c07\u6703\u91cd\u65b0\u5c0e\u5411\u81f3 Hyperion UI \u4ee5\u6838\u51c6\u8981\u6c42\u3002\u8acb\u78ba\u8a8d\u986f\u793a ID \u70ba \"{auth_id}\"", - "title": "\u81ea\u52d5\u65b0\u5275\u8a8d\u8b49\u5bc6\u9470" + "description": "\u9ede\u9078\u4e0b\u65b9 **\u50b3\u9001** \u4ee5\u8acb\u6c42\u65b0\u8a8d\u8b49\u6b0a\u6756\u3002\u5c07\u6703\u91cd\u65b0\u5c0e\u5411\u81f3 Hyperion UI \u4ee5\u6838\u51c6\u8981\u6c42\u3002\u8acb\u78ba\u8a8d\u986f\u793a ID \u70ba \"{auth_id}\"", + "title": "\u81ea\u52d5\u65b0\u5275\u8a8d\u8b49\u6b0a\u6756" }, "create_token_external": { - "title": "\u63a5\u53d7 Hyperion UI \u4e2d\u7684\u65b0\u5bc6\u9470" + "title": "\u63a5\u53d7 Hyperion UI \u4e2d\u7684\u65b0\u6b0a\u6756" }, "user": { "data": { diff --git a/homeassistant/components/iaqualink/translations/ko.json b/homeassistant/components/iaqualink/translations/ko.json index 6396d5d250e..1386480fca4 100644 --- a/homeassistant/components/iaqualink/translations/ko.json +++ b/homeassistant/components/iaqualink/translations/ko.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/icloud/translations/ca.json b/homeassistant/components/icloud/translations/ca.json index a0d74aba98c..6e92897161a 100644 --- a/homeassistant/components/icloud/translations/ca.json +++ b/homeassistant/components/icloud/translations/ca.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "send_verification_code": "No s'ha pogut enviar el codi de verificaci\u00f3", - "validate_verification_code": "No s'ha pogut verificar el codi de verificaci\u00f3, tria un dispositiu de confian\u00e7a i torna a iniciar el proc\u00e9s" + "validate_verification_code": "No s'ha pogut verificar el codi de verificaci\u00f3, torna-ho a provar" }, "step": { "reauth": { diff --git a/homeassistant/components/icloud/translations/et.json b/homeassistant/components/icloud/translations/et.json index 29b24aabf5a..af3457bb0db 100644 --- a/homeassistant/components/icloud/translations/et.json +++ b/homeassistant/components/icloud/translations/et.json @@ -15,7 +15,7 @@ "data": { "password": "Salas\u00f5na" }, - "description": "Varem sisestatud salas\u00f5na kasutajale {username} ei t\u00f6\u00f6ta enam. Selle sidumise kasutamise j\u00e4tkamiseks v\u00e4rskendage oma salas\u00f5na.", + "description": "Varem sisestatud salas\u00f5na kasutajale {username} ei t\u00f6\u00f6ta enam. Selle sidumise kasutamise j\u00e4tkamiseks v\u00e4rskenda oma salas\u00f5na.", "title": "iCloudi tuvastusandmed" }, "trusted_device": { diff --git a/homeassistant/components/icloud/translations/it.json b/homeassistant/components/icloud/translations/it.json index 4fde8b33526..cfb18caee1e 100644 --- a/homeassistant/components/icloud/translations/it.json +++ b/homeassistant/components/icloud/translations/it.json @@ -3,12 +3,12 @@ "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "no_device": "Nessuno dei tuoi dispositivi ha attivato \"Trova il mio iPhone\"", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida", "send_verification_code": "Impossibile inviare il codice di verifica", - "validate_verification_code": "Impossibile verificare il codice di verifica, scegliere un dispositivo attendibile e riavviare la verifica" + "validate_verification_code": "Impossibile verificare il codice di verifica, riprovare" }, "step": { "reauth": { @@ -16,7 +16,7 @@ "password": "Password" }, "description": "La password inserita in precedenza per {username} non funziona pi\u00f9. Aggiorna la tua password per continuare a utilizzare questa integrazione.", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" }, "trusted_device": { "data": { diff --git a/homeassistant/components/icloud/translations/ko.json b/homeassistant/components/icloud/translations/ko.json index 045042362b7..5e02fb02993 100644 --- a/homeassistant/components/icloud/translations/ko.json +++ b/homeassistant/components/icloud/translations/ko.json @@ -1,14 +1,22 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "no_device": "\"\ub098\uc758 iPhone \ucc3e\uae30\"\uac00 \ud65c\uc131\ud654\ub41c \uae30\uae30\uac00 \uc5c6\uc2b5\ub2c8\ub2e4" + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_device": "\"\ub098\uc758 iPhone \ucc3e\uae30\"\uac00 \ud65c\uc131\ud654\ub41c \uae30\uae30\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "send_verification_code": "\uc778\uc99d \ucf54\ub4dc\ub97c \ubcf4\ub0b4\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "validate_verification_code": "\uc778\uc99d \ucf54\ub4dc\ub97c \ud655\uc778\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc2e0\ub8b0\ud560 \uc218 \uc788\ub294 \uae30\uae30\ub97c \uc120\ud0dd\ud558\uace0 \uc778\uc99d\uc744 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" + "validate_verification_code": "\uc778\uc99d \ucf54\ub4dc \ud655\uc778\uc5d0 \uc2e4\ud328\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" }, "step": { + "reauth": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + }, + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" + }, "trusted_device": { "data": { "trusted_device": "\uc2e0\ub8b0\ud560 \uc218 \uc788\ub294 \uae30\uae30" diff --git a/homeassistant/components/icloud/translations/nl.json b/homeassistant/components/icloud/translations/nl.json index 537d310b0a7..b150c8d5b16 100644 --- a/homeassistant/components/icloud/translations/nl.json +++ b/homeassistant/components/icloud/translations/nl.json @@ -2,9 +2,11 @@ "config": { "abort": { "already_configured": "Account reeds geconfigureerd", - "no_device": "Op geen van uw apparaten is \"Find my iPhone\" geactiveerd" + "no_device": "Op geen van uw apparaten is \"Find my iPhone\" geactiveerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { + "invalid_auth": "Ongeldige authenticatie", "send_verification_code": "Kan verificatiecode niet verzenden", "validate_verification_code": "Kan uw verificatiecode niet verifi\u00ebren, kies een vertrouwensapparaat en start de verificatie opnieuw" }, @@ -13,7 +15,8 @@ "data": { "password": "Wachtwoord" }, - "description": "Uw eerder ingevoerde wachtwoord voor {username} werkt niet meer. Update uw wachtwoord om deze integratie te blijven gebruiken." + "description": "Uw eerder ingevoerde wachtwoord voor {username} werkt niet meer. Update uw wachtwoord om deze integratie te blijven gebruiken.", + "title": "Verifieer de integratie opnieuw" }, "trusted_device": { "data": { @@ -25,6 +28,7 @@ "user": { "data": { "password": "Wachtwoord", + "username": "E-mail", "with_family": "Met gezin" }, "description": "Voer uw gegevens in", diff --git a/homeassistant/components/icloud/translations/no.json b/homeassistant/components/icloud/translations/no.json index 62e123eb84c..3e20aef032e 100644 --- a/homeassistant/components/icloud/translations/no.json +++ b/homeassistant/components/icloud/translations/no.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Ugyldig godkjenning", "send_verification_code": "Kunne ikke sende bekreftelseskode", - "validate_verification_code": "Kunne ikke bekrefte bekreftelseskoden din, velg en tillitsenhet og start bekreftelsen p\u00e5 nytt" + "validate_verification_code": "Kunne ikke bekrefte bekreftelseskoden, pr\u00f8v p\u00e5 nytt" }, "step": { "reauth": { diff --git a/homeassistant/components/icloud/translations/pl.json b/homeassistant/components/icloud/translations/pl.json index 4ac02d1f3f0..e111518710b 100644 --- a/homeassistant/components/icloud/translations/pl.json +++ b/homeassistant/components/icloud/translations/pl.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Niepoprawne uwierzytelnienie", "send_verification_code": "Nie uda\u0142o si\u0119 wys\u0142a\u0107 kodu weryfikacyjnego", - "validate_verification_code": "Nie uda\u0142o si\u0119 zweryfikowa\u0107 kodu weryfikacyjnego, wybierz urz\u0105dzenie zaufane i ponownie rozpocznij weryfikacj\u0119" + "validate_verification_code": "Nie uda\u0142o si\u0119 zweryfikowa\u0107 kodu weryfikacyjnego, spr\u00f3buj ponownie" }, "step": { "reauth": { diff --git a/homeassistant/components/icloud/translations/ru.json b/homeassistant/components/icloud/translations/ru.json index d977899d902..bdd6fe776ad 100644 --- a/homeassistant/components/icloud/translations/ru.json +++ b/homeassistant/components/icloud/translations/ru.json @@ -6,9 +6,9 @@ "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "send_verification_code": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f.", - "validate_verification_code": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f, \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u043e\u0432\u0435\u0440\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0438 \u043d\u0430\u0447\u043d\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u0441\u043d\u043e\u0432\u0430." + "validate_verification_code": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437." }, "step": { "reauth": { diff --git a/homeassistant/components/icloud/translations/sv.json b/homeassistant/components/icloud/translations/sv.json index 6caf02f56c5..2bba72d49df 100644 --- a/homeassistant/components/icloud/translations/sv.json +++ b/homeassistant/components/icloud/translations/sv.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Kontot har redan konfigurerats" + "already_configured": "Kontot har redan konfigurerats", + "no_device": "Ingen av dina enheter har \"Hitta min iPhone\" aktiverat" }, "error": { "send_verification_code": "Det gick inte att skicka verifieringskod", diff --git a/homeassistant/components/icloud/translations/zh-Hant.json b/homeassistant/components/icloud/translations/zh-Hant.json index 1c16db77faf..fe421275e2a 100644 --- a/homeassistant/components/icloud/translations/zh-Hant.json +++ b/homeassistant/components/icloud/translations/zh-Hant.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "send_verification_code": "\u50b3\u9001\u9a57\u8b49\u78bc\u5931\u6557", - "validate_verification_code": "\u7121\u6cd5\u9a57\u8b49\u8f38\u5165\u9a57\u8b49\u78bc\uff0c\u9078\u64c7\u4e00\u90e8\u4fe1\u4efb\u88dd\u7f6e\u3001\u7136\u5f8c\u91cd\u65b0\u57f7\u884c\u9a57\u8b49\u3002" + "validate_verification_code": "\u9a57\u8b49\u8f38\u5165\u9a57\u8b49\u78bc\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" }, "step": { "reauth": { diff --git a/homeassistant/components/ifttt/translations/ko.json b/homeassistant/components/ifttt/translations/ko.json index 93daad9e182..bc561027fc3 100644 --- a/homeassistant/components/ifttt/translations/ko.json +++ b/homeassistant/components/ifttt/translations/ko.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT \uc6f9 \ud6c5 \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/ifttt/translations/nl.json b/homeassistant/components/ifttt/translations/nl.json index e7da47dd658..82006860db3 100644 --- a/homeassistant/components/ifttt/translations/nl.json +++ b/homeassistant/components/ifttt/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar de Home Assistant te verzenden, moet u de actie \"Een webverzoek doen\" gebruiken vanuit de [IFTTT Webhook-applet]({applet_url}). \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [the documentation]({docs_url}) voor informatie over het configureren van automatiseringen om inkomende gegevens te verwerken." diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index cc06110c111..0db580701d0 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -165,7 +165,7 @@ class IgnSismologiaLocationEvent(GeolocationEvent): """Remove this entity.""" self._remove_signal_delete() self._remove_signal_update() - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index f200c9651f0..c539156b759 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -23,6 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType from .const import ( + ATTR_CONTROLLER_ID, ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, @@ -186,13 +187,18 @@ AUTO_SETUP_SCHEMA = vol.Schema( ) SET_RUNTIME_VALUE_BOOL_SCHEMA = vol.Schema( - {vol.Required(ATTR_IHC_ID): cv.positive_int, vol.Required(ATTR_VALUE): cv.boolean} + { + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Required(ATTR_VALUE): cv.boolean, + vol.Optional(ATTR_CONTROLLER_ID, default=0): cv.positive_int, + } ) SET_RUNTIME_VALUE_INT_SCHEMA = vol.Schema( { vol.Required(ATTR_IHC_ID): cv.positive_int, vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Optional(ATTR_CONTROLLER_ID, default=0): cv.positive_int, } ) @@ -200,10 +206,16 @@ SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema( { vol.Required(ATTR_IHC_ID): cv.positive_int, vol.Required(ATTR_VALUE): vol.Coerce(float), + vol.Optional(ATTR_CONTROLLER_ID, default=0): cv.positive_int, } ) -PULSE_SCHEMA = vol.Schema({vol.Required(ATTR_IHC_ID): cv.positive_int}) +PULSE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Optional(ATTR_CONTROLLER_ID, default=0): cv.positive_int, + } +) def setup(hass, config): @@ -218,7 +230,6 @@ def setup(hass, config): def ihc_setup(hass, config, conf, controller_id): """Set up the IHC component.""" - url = conf[CONF_URL] username = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] @@ -237,7 +248,9 @@ def ihc_setup(hass, config, conf, controller_id): # Store controller configuration ihc_key = f"ihc{controller_id}" hass.data[ihc_key] = {IHC_CONTROLLER: ihc_controller, IHC_INFO: conf[CONF_INFO]} - setup_service_functions(hass, ihc_controller) + # We only want to register the service functions once for the first controller + if controller_id == 0: + setup_service_functions(hass) return True @@ -275,7 +288,6 @@ def autosetup_ihc_products( hass: HomeAssistantType, 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: _LOGGER.error("Unable to read project from IHC controller") @@ -329,30 +341,39 @@ def get_discovery_info(component_setup, groups, controller_id): return discovery_data -def setup_service_functions(hass: HomeAssistantType, ihc_controller): +def setup_service_functions(hass: HomeAssistantType): """Set up the IHC service functions.""" + def _get_controller(call): + controller_id = call.data[ATTR_CONTROLLER_ID] + ihc_key = f"ihc{controller_id}" + return hass.data[ihc_key][IHC_CONTROLLER] + def set_runtime_value_bool(call): """Set a IHC runtime bool value service function.""" ihc_id = call.data[ATTR_IHC_ID] value = call.data[ATTR_VALUE] + ihc_controller = _get_controller(call) ihc_controller.set_runtime_value_bool(ihc_id, value) def set_runtime_value_int(call): """Set a IHC runtime integer value service function.""" ihc_id = call.data[ATTR_IHC_ID] value = call.data[ATTR_VALUE] + ihc_controller = _get_controller(call) ihc_controller.set_runtime_value_int(ihc_id, value) def set_runtime_value_float(call): """Set a IHC runtime float value service function.""" ihc_id = call.data[ATTR_IHC_ID] value = call.data[ATTR_VALUE] + ihc_controller = _get_controller(call) ihc_controller.set_runtime_value_float(ihc_id, value) async def async_pulse_runtime_input(call): """Pulse a IHC controller input function.""" ihc_id = call.data[ATTR_IHC_ID] + ihc_controller = _get_controller(call) await async_pulse(hass, ihc_controller, ihc_id) hass.services.register( diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index 15db19ba58b..c751d7990e4 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -6,7 +6,6 @@ CONF_DIMMABLE = "dimmable" CONF_INFO = "info" CONF_INVERTING = "inverting" CONF_LIGHT = "light" -CONF_NAME = "name" CONF_NODE = "node" CONF_NOTE = "note" CONF_OFF_ID = "off_id" @@ -18,6 +17,7 @@ CONF_XPATH = "xpath" ATTR_IHC_ID = "ihc_id" ATTR_VALUE = "value" +ATTR_CONTROLLER_ID = "controller_id" SERVICE_SET_RUNTIME_VALUE_BOOL = "set_runtime_value_bool" SERVICE_SET_RUNTIME_VALUE_FLOAT = "set_runtime_value_float" diff --git a/homeassistant/components/ihc/ihc_auto_setup.yaml b/homeassistant/components/ihc/ihc_auto_setup.yaml index d5f8d26e2b7..7a94afdae44 100644 --- a/homeassistant/components/ihc/ihc_auto_setup.yaml +++ b/homeassistant/components/ihc/ihc_auto_setup.yaml @@ -34,6 +34,30 @@ binary_sensor: type: "light" light: + # Swedish Wireless dimmer (Mobil VU/Dimmer 1-knapp/touch) + - xpath: './/product_airlink[@product_identifier="_0x4301"]' + node: "airlink_dimming" + dimmable: true + # Swedish Wireless dimmer (Lamputtag/Dimmer 1-knapp/touch) + - xpath: './/product_airlink[@product_identifier="_0x4302"]' + node: "airlink_dimming" + dimmable: true + # Swedish Wireless dimmer (Blind/Dimmer 1-knapp/touch) + - xpath: './/product_airlink[@product_identifier="_0x4305"]' + node: "airlink_dimming" + dimmable: true + # Swedish Wireless dimmer (3-tråds Puck/Dimmer 1-knapp/touch) + - xpath: './/product_airlink[@product_identifier="_0x4307"]' + node: "airlink_dimming" + dimmable: true + # Swedish Wireless dimmer (3-tråds Puck/Dimmer 2-knapp) + - xpath: './/product_airlink[@product_identifier="_0x4308"]' + node: "airlink_dimming" + dimmable: true + # 2 channel RS485 dimmer + - xpath: './/rs485_led_dimmer_channel[@product_identifier="_0x4410"]' + node: "airlink_dimming" + dimmable: true # Wireless Combi dimmer 4 buttons - xpath: './/product_airlink[@product_identifier="_0x4406"]' node: "airlink_dimming" diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml index ad41539162c..a65d5f5b78c 100644 --- a/homeassistant/components/ihc/services.yaml +++ b/homeassistant/components/ihc/services.yaml @@ -3,29 +3,56 @@ set_runtime_value_bool: description: Set a boolean runtime value on the IHC controller. fields: + controller_id: + description: | + If you have multiple controller, this is the index of you controller + starting with 0 (0 is default) + example: 0 ihc_id: description: The integer IHC resource ID. + example: 123456 value: description: The boolean value to set. + example: true set_runtime_value_int: description: Set an integer runtime value on the IHC controller. fields: + controller_id: + description: | + If you have multiple controller, this is the index of you controller + starting with 0 (0 is default) + example: 0 ihc_id: description: The integer IHC resource ID. + example: 123456 value: description: The integer value to set. + example: 50 set_runtime_value_float: description: Set a float runtime value on the IHC controller. fields: + controller_id: + description: | + If you have multiple controller, this is the index of you controller + starting with 0 (0 is default) + example: 0 ihc_id: description: The integer IHC resource ID. + example: 123456 value: description: The float value to set. + example: 1.47 pulse: description: Pulses an input on the IHC controller. fields: + controller_id: + description: | + If you have multiple controller, this is the index of you controller + starting with 0 (0 is default) + example: 0 ihc_id: description: The integer IHC resource ID. + example: 123456 diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json index 6978f09ab68..c8029c2e313 100644 --- a/homeassistant/components/image/manifest.json +++ b/homeassistant/components/image/manifest.json @@ -3,7 +3,7 @@ "name": "Image", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/image", - "requirements": ["pillow==8.1.0"], + "requirements": ["pillow==8.1.1"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 82672c22015..e885a9ca7a9 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -5,7 +5,13 @@ import logging import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, CONF_ENTITY_ID, CONF_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_NAME, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, +) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -39,7 +45,6 @@ ATTR_GLASSES = "glasses" ATTR_MOTION = "motion" ATTR_TOTAL_FACES = "total_faces" -CONF_SOURCE = "source" CONF_CONFIDENCE = "confidence" DEFAULT_TIMEOUT = 10 @@ -76,7 +81,7 @@ async def async_setup(hass, config): update_tasks = [] for entity in image_entities: entity.async_set_context(service.context) - update_tasks.append(entity.async_update_ha_state(True)) + update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) if update_tasks: await asyncio.wait(update_tasks) diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 69e455344b0..cd074acd9f4 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -1,8 +1,5 @@ # Describes the format for available image processing services scan: - description: Process an image immediately. - fields: - entity_id: - description: Name(s) of entities to scan immediately. - example: "image_processing.alpr_garage" + description: Process an image immediately + target: diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 16b6971b11f..e327f34d128 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -62,6 +62,7 @@ from .const import ( CONF_PRECISION, CONF_RETRY_COUNT, CONF_SSL, + CONF_SSL_CA_CERT, CONF_TAGS, CONF_TAGS_ATTRIBUTES, CONF_TOKEN, @@ -335,6 +336,9 @@ def get_influx_connection(conf, test_write=False, test_read=False): kwargs[CONF_URL] = conf[CONF_URL] kwargs[CONF_TOKEN] = conf[CONF_TOKEN] kwargs[INFLUX_CONF_ORG] = conf[CONF_ORG] + kwargs[CONF_VERIFY_SSL] = conf[CONF_VERIFY_SSL] + if CONF_SSL_CA_CERT in conf: + kwargs[CONF_SSL_CA_CERT] = conf[CONF_SSL_CA_CERT] bucket = conf.get(CONF_BUCKET) influx = InfluxDBClientV2(**kwargs) query_api = influx.query_api() @@ -392,7 +396,10 @@ def get_influx_connection(conf, test_write=False, test_read=False): return InfluxClient(buckets, write_v2, query_v2, close_v2) # Else it's a V1 client - kwargs[CONF_VERIFY_SSL] = conf[CONF_VERIFY_SSL] + if CONF_SSL_CA_CERT in conf and conf[CONF_VERIFY_SSL]: + kwargs[CONF_VERIFY_SSL] = conf[CONF_SSL_CA_CERT] + else: + kwargs[CONF_VERIFY_SSL] = conf[CONF_VERIFY_SSL] if CONF_DB_NAME in conf: kwargs[CONF_DB_NAME] = conf[CONF_DB_NAME] diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index 1a827c1b63c..e66a0fe10c4 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -31,6 +31,7 @@ CONF_COMPONENT_CONFIG_DOMAIN = "component_config_domain" CONF_RETRY_COUNT = "max_retries" CONF_IGNORE_ATTRIBUTES = "ignore_attributes" CONF_PRECISION = "precision" +CONF_SSL_CA_CERT = "ssl_ca_cert" CONF_LANGUAGE = "language" CONF_QUERIES = "queries" @@ -139,12 +140,13 @@ COMPONENT_CONFIG_SCHEMA_CONNECTION = { vol.Optional(CONF_PATH): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_SSL_CA_CERT): cv.isfile, vol.Optional(CONF_PRECISION): vol.In(["ms", "s", "us", "ns"]), # Connection config for V1 API only. vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, # Connection config for V2 API only. vol.Inclusive(CONF_TOKEN, "v2_authentication"): cv.string, vol.Inclusive(CONF_ORG, "v2_authentication"): cv.string, diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index ec1bd8f9594..c2d6f77e7c1 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -2,6 +2,6 @@ "domain": "influxdb", "name": "InfluxDB", "documentation": "https://www.home-assistant.io/integrations/influxdb", - "requirements": ["influxdb==5.2.3", "influxdb-client==1.8.0"], + "requirements": ["influxdb==5.2.3", "influxdb-client==1.14.0"], "codeowners": ["@fabaff", "@mdegat01"] } diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index f123d6d3297..fbfe4cd0454 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -1,4 +1,6 @@ """Support to keep track of user controlled booleans for within automation.""" +from __future__ import annotations + import logging import typing @@ -89,8 +91,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, lambda conf: InputBoolean(conf, from_yaml=True) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputBoolean.from_yaml ) storage_collection = InputBooleanStorageCollection( @@ -98,8 +100,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, InputBoolean + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputBoolean ) await yaml_collection.async_load( @@ -111,9 +113,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Remove all input booleans and load new ones from config.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -146,14 +145,19 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: class InputBoolean(ToggleEntity, RestoreEntity): """Representation of a boolean input.""" - def __init__(self, config: typing.Optional[dict], from_yaml: bool = False): + def __init__(self, config: typing.Optional[dict]): """Initialize a boolean input.""" self._config = config - self._editable = True + self.editable = True self._state = config.get(CONF_INITIAL) - if from_yaml: - self._editable = False - self.entity_id = f"{DOMAIN}.{self.unique_id}" + + @classmethod + def from_yaml(cls, config: typing.Dict) -> InputBoolean: + """Return entity instance initialized from yaml storage.""" + input_bool = cls(config) + input_bool.entity_id = f"{DOMAIN}.{config[CONF_ID]}" + input_bool.editable = False + return input_bool @property def should_poll(self): @@ -168,7 +172,7 @@ class InputBoolean(ToggleEntity, RestoreEntity): @property def state_attributes(self): """Return the state attributes of the entity.""" - return {ATTR_EDITABLE: self._editable} + return {ATTR_EDITABLE: self.editable} @property def icon(self): diff --git a/homeassistant/components/input_boolean/services.yaml b/homeassistant/components/input_boolean/services.yaml index 5ab5e7a9b82..8cefe2b4974 100644 --- a/homeassistant/components/input_boolean/services.yaml +++ b/homeassistant/components/input_boolean/services.yaml @@ -1,20 +1,14 @@ toggle: - description: Toggles an input boolean. - fields: - entity_id: - description: Entity id of the input boolean to toggle. - example: input_boolean.notify_alerts + description: Toggle an input boolean + target: + turn_off: - description: Turns off an input boolean - fields: - entity_id: - description: Entity id of the input boolean to turn off. - example: input_boolean.notify_alerts + description: Turn off an input boolean + target: + turn_on: - description: Turns on an input boolean. - fields: - entity_id: - description: Entity id of the input boolean to turn on. - example: input_boolean.notify_alerts + description: Turn on an input boolean + target: + reload: - description: Reload the input_boolean configuration. + description: Reload the input_boolean configuration diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 0eab810245d..adefa36639a 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -1,4 +1,6 @@ """Support to select a date and/or a time.""" +from __future__ import annotations + import datetime as py_datetime import logging import typing @@ -108,8 +110,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, InputDatetime.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputDatetime.from_yaml ) storage_collection = DateTimeStorageCollection( @@ -117,8 +119,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, InputDatetime + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputDatetime ) await yaml_collection.async_load( @@ -130,9 +132,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -231,7 +230,7 @@ class InputDatetime(RestoreEntity): ) @classmethod - def from_yaml(cls, config: typing.Dict) -> "InputDatetime": + def from_yaml(cls, config: typing.Dict) -> InputDatetime: """Return entity instance initialized from yaml storage.""" input_dt = cls(config) input_dt.entity_id = f"{DOMAIN}.{config[CONF_ID]}" diff --git a/homeassistant/components/input_datetime/services.yaml b/homeassistant/components/input_datetime/services.yaml index bcbadc45aad..0243ca9f67d 100644 --- a/homeassistant/components/input_datetime/services.yaml +++ b/homeassistant/components/input_datetime/services.yaml @@ -1,21 +1,38 @@ set_datetime: - description: This can be used to dynamically set the date and/or time. Use date/time, datetime or timestamp. + name: Set + description: This can be used to dynamically set the date and/or time. + target: fields: - entity_id: - description: Entity id of the input datetime to set the new value. - example: input_datetime.test_date_time date: + name: Date description: The target date the entity should be set to. example: '"2019-04-20"' + selector: + text: time: + name: Time description: The target time the entity should be set to. example: '"05:04:20"' + selector: + time: datetime: + name: Date & Time description: The target date & time the entity should be set to. example: '"2019-04-20 05:04:20"' + selector: + text: timestamp: - description: The target date & time the entity should be set to as expressed by a UNIX timestamp. + name: Timestamp + description: + The target date & time the entity should be set to as expressed by a + UNIX timestamp. example: 1598027400 + selector: + number: + min: 0 + max: 9223372036854775807 + mode: box reload: + name: Reload description: Reload the input_datetime configuration. diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 1f979cad7a9..b68e6fff45d 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -1,4 +1,6 @@ """Support to set a numeric value from a slider or text box.""" +from __future__ import annotations + import logging import typing @@ -119,8 +121,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, InputNumber.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputNumber.from_yaml ) storage_collection = NumberStorageCollection( @@ -128,8 +130,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, InputNumber + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputNumber ) await yaml_collection.async_load( @@ -141,9 +143,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -205,7 +204,7 @@ class InputNumber(RestoreEntity): self._current_value = config.get(CONF_INITIAL) @classmethod - def from_yaml(cls, config: typing.Dict) -> "InputNumber": + def from_yaml(cls, config: typing.Dict) -> InputNumber: """Return entity instance initialized from yaml storage.""" input_num = cls(config) input_num.entity_id = f"{DOMAIN}.{config[CONF_ID]}" diff --git a/homeassistant/components/input_number/services.yaml b/homeassistant/components/input_number/services.yaml index 4d69bf72eda..7d388238022 100644 --- a/homeassistant/components/input_number/services.yaml +++ b/homeassistant/components/input_number/services.yaml @@ -1,23 +1,30 @@ decrement: + name: Decrement description: Decrement the value of an input number entity by its stepping. - fields: - entity_id: - description: Entity id of the input number that should be decremented. - example: input_number.threshold + target: + increment: + name: Increment description: Increment the value of an input number entity by its stepping. - fields: - entity_id: - description: Entity id of the input number that should be incremented. - example: input_number.threshold + target: + set_value: + name: Set description: Set the value of an input number entity. + target: fields: - entity_id: - description: Entity id of the input number to set the new value. - example: input_number.threshold value: + name: Value description: The target value the entity should be set to. + required: true example: 42 + selector: + number: + min: 0 + max: 9223372036854775807 + step: 0.001 + mode: box + reload: + name: Reload description: Reload the input_number configuration. diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 6272992f243..f6831dc3e88 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -1,4 +1,6 @@ """Support to select an option from a list.""" +from __future__ import annotations + import logging import typing @@ -94,8 +96,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, InputSelect.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputSelect.from_yaml ) storage_collection = InputSelectStorageCollection( @@ -103,8 +105,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, InputSelect + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputSelect ) await yaml_collection.async_load( @@ -116,9 +118,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -210,7 +209,7 @@ class InputSelect(RestoreEntity): self._current_option = config.get(CONF_INITIAL) @classmethod - def from_yaml(cls, config: typing.Dict) -> "InputSelect": + def from_yaml(cls, config: typing.Dict) -> InputSelect: """Return entity instance initialized from yaml storage.""" input_select = cls(config) input_select.entity_id = f"{DOMAIN}.{config[CONF_ID]}" diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml index 0eddb158d34..f8fbe158aab 100644 --- a/homeassistant/components/input_select/services.yaml +++ b/homeassistant/components/input_select/services.yaml @@ -1,50 +1,65 @@ select_next: + name: Next description: Select the next options of an input select entity. + target: fields: - entity_id: - description: Entity id of the input select to select the next value for. - example: input_select.my_select cycle: - description: If the option should cycle from the last to the first (defaults to true). + name: Cycle + description: If the option should cycle from the last to the first. + default: true example: true + selector: + boolean: + select_option: + name: Select description: Select an option of an input select entity. + target: fields: - entity_id: - description: Entity id of the input select to select the value. - example: input_select.my_select option: + name: Option description: Option to be selected. + required: true example: '"Item A"' + selector: + text: + select_previous: + name: Previous description: Select the previous options of an input select entity. + target: fields: - entity_id: - description: Entity id of the input select to select the previous value for. - example: input_select.my_select cycle: - description: If the option should cycle from the first to the last (defaults to true). + name: Cycle + description: If the option should cycle from the first to the last. + default: true example: true + selector: + boolean: + select_first: + name: First description: Select the first option of an input select entity. - fields: - entity_id: - description: Entity id of the input select to select the first value for. - example: input_select.my_select + target: + select_last: + name: Last description: Select the last option of an input select entity. - fields: - entity_id: - description: Entity id of the input select to select the last value for. - example: input_select.my_select + target: + set_options: + name: Set options description: Set the options of an input select entity. + target: fields: - entity_id: - description: Entity id of the input select to set the new options for. - example: input_select.my_select options: + name: Options description: Options for the input select entity. + required: true example: '["Item A", "Item B", "Item C"]' + selector: + object: + reload: + name: Reload description: Reload the input_select configuration. diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index c512bc221db..3f8c1d6a13e 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -1,4 +1,6 @@ """Support to enter a value into a text box.""" +from __future__ import annotations + import logging import typing @@ -119,8 +121,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, InputText.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputText.from_yaml ) storage_collection = InputTextStorageCollection( @@ -128,8 +130,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, InputText + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputText ) await yaml_collection.async_load( @@ -141,9 +143,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -199,7 +198,7 @@ class InputText(RestoreEntity): self._current_value = config.get(CONF_INITIAL) @classmethod - def from_yaml(cls, config: typing.Dict) -> "InputText": + def from_yaml(cls, config: typing.Dict) -> InputText: """Return entity instance initialized from yaml storage.""" input_text = cls(config) input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}" diff --git a/homeassistant/components/input_text/services.yaml b/homeassistant/components/input_text/services.yaml index 0f74cd8940e..5983683ec6d 100644 --- a/homeassistant/components/input_text/services.yaml +++ b/homeassistant/components/input_text/services.yaml @@ -1,11 +1,16 @@ set_value: + name: Set description: Set the value of an input text entity. + target: fields: - entity_id: - description: Entity id of the input text to set the new value. - example: input_text.text1 value: + name: Value description: The target value the entity should be set to. + required: true example: This is an example text + selector: + text: + reload: + name: Reload description: Reload the input_text configuration. diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index ad87c69bd0f..69a4a5f5280 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -27,6 +27,7 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SIGNAL_ADD_ENTITIES @@ -51,7 +52,8 @@ SENSOR_TYPES = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon binary sensors from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_binary_sensor_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, @@ -62,8 +64,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) signal = f"{SIGNAL_ADD_ENTITIES}_{BINARY_SENSOR_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) + async_add_insteon_binary_sensor_entities() class InsteonBinarySensorEntity(InsteonEntity, BinarySensorEntity): diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 7d4d9543c3f..c699e76c4f3 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -25,6 +25,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SIGNAL_ADD_ENTITIES @@ -64,7 +65,8 @@ SUPPORTED_FEATURES = ( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon climate entities from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_climate_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, @@ -75,8 +77,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) signal = f"{SIGNAL_ADD_ENTITIES}_{CLIMATE_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) + async_add_insteon_climate_entities() class InsteonClimateEntity(InsteonEntity, ClimateEntity): diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 498d194667c..fd20637b174 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( SUPPORT_SET_POSITION, CoverEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SIGNAL_ADD_ENTITIES @@ -21,15 +22,16 @@ SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon covers from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_cover_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, COVER_DOMAIN, InsteonCoverEntity, async_add_entities, discovery_info ) signal = f"{SIGNAL_ADD_ENTITIES}_{COVER_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) + async_add_insteon_cover_entities() class InsteonCoverEntity(InsteonEntity, CoverEntity): diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index fb741d7a4b0..a641d353450 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -1,82 +1,77 @@ """Support for INSTEON fans via PowerLinc Modem.""" +import math + from pyinsteon.constants import FanSpeed from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_SET_SPEED, FanEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity from .utils import async_add_insteon_entities -FAN_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] -SPEED_TO_VALUE = { - SPEED_OFF: FanSpeed.OFF, - SPEED_LOW: FanSpeed.LOW, - SPEED_MEDIUM: FanSpeed.MEDIUM, - SPEED_HIGH: FanSpeed.HIGH, -} +SPEED_RANGE = (1, FanSpeed.HIGH) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon fans from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_fan_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, FAN_DOMAIN, InsteonFanEntity, async_add_entities, discovery_info ) signal = f"{SIGNAL_ADD_ENTITIES}_{FAN_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) + async_add_insteon_fan_entities() class InsteonFanEntity(InsteonEntity, FanEntity): """An INSTEON fan entity.""" @property - def speed(self) -> str: - """Return the current speed.""" - if self._insteon_device_group.value == FanSpeed.HIGH: - return SPEED_HIGH - if self._insteon_device_group.value == FanSpeed.MEDIUM: - return SPEED_MEDIUM - if self._insteon_device_group.value == FanSpeed.LOW: - return SPEED_LOW - return SPEED_OFF - - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return FAN_SPEEDS + def percentage(self) -> str: + """Return the current speed percentage.""" + if self._insteon_device_group.value is None: + return None + return ranged_value_to_percentage(SPEED_RANGE, self._insteon_device_group.value) @property def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" - if speed is None: - speed = SPEED_MEDIUM - await self.async_set_speed(speed) + if percentage is None: + percentage = 50 + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: """Turn off the fan.""" await self._insteon_device.async_fan_off() - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - fan_speed = SPEED_TO_VALUE[speed] - if fan_speed == FanSpeed.OFF: + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: await self._insteon_device.async_fan_off() else: - await self._insteon_device.async_fan_on(on_level=fan_speed) + on_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + await self._insteon_device.async_fan_on(on_level=on_level) diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index 3bef7dd0247..2234eb4750c 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -1,4 +1,5 @@ """Insteon base entity.""" +import functools import logging from pyinsteon import devices @@ -84,7 +85,7 @@ class InsteonEntity(Entity): return { "identifiers": {(DOMAIN, str(self._insteon_device.address))}, "name": f"{self._insteon_device.description} {self._insteon_device.address}", - "model": f"{self._insteon_device.model} (0x{self._insteon_device.cat:02x}, 0x{self._insteon_device.subcat:02x})", + "model": f"{self._insteon_device.model} ({self._insteon_device.cat!r}, 0x{self._insteon_device.subcat:02x})", "sw_version": f"{self._insteon_device.firmware:02x} Engine Version: {self._insteon_device.engine_version}", "manufacturer": "Smart Home", "via_device": (DOMAIN, str(devices.modem.address)), @@ -122,7 +123,11 @@ class InsteonEntity(Entity): ) remove_signal = f"{self._insteon_device.address.id}_{SIGNAL_REMOVE_ENTITY}" self.async_on_remove( - async_dispatcher_connect(self.hass, remove_signal, self.async_remove) + async_dispatcher_connect( + self.hass, + remove_signal, + functools.partial(self.async_remove, force_remove=True), + ) ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index f49dafed2fe..206aa078dc3 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -6,6 +6,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, LightEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SIGNAL_ADD_ENTITIES @@ -18,15 +19,16 @@ MAX_BRIGHTNESS = 255 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon lights from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_light_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, LIGHT_DOMAIN, InsteonDimmerEntity, async_add_entities, discovery_info ) signal = f"{SIGNAL_ADD_ENTITIES}_{LIGHT_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) + async_add_insteon_light_entities() class InsteonDimmerEntity(InsteonEntity, LightEntity): diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index d20f56054b3..57c750c4429 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,7 +2,7 @@ "domain": "insteon", "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", - "requirements": ["pyinsteon==1.0.8"], + "requirements": ["pyinsteon==1.0.9"], "codeowners": ["@teharris1"], "config_flow": true } \ No newline at end of file diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index adc7e945eba..8698a358b21 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -187,47 +187,47 @@ def add_device_override(config_data, new_override): except ValueError as err: raise ValueError("Incorrect values") from err - overrides = config_data.get(CONF_OVERRIDE, []) + overrides = [] + + for override in config_data.get(CONF_OVERRIDE, []): + if override[CONF_ADDRESS] != address: + overrides.append(override) + curr_override = {} - - # If this address has an override defined, remove it - for override in overrides: - if override[CONF_ADDRESS] == address: - curr_override = override - break - if curr_override: - overrides.remove(curr_override) - curr_override[CONF_ADDRESS] = address curr_override[CONF_CAT] = cat curr_override[CONF_SUBCAT] = subcat overrides.append(curr_override) - config_data[CONF_OVERRIDE] = overrides - return config_data + + new_config = {} + if config_data.get(CONF_X10): + new_config[CONF_X10] = config_data[CONF_X10] + new_config[CONF_OVERRIDE] = overrides + return new_config def add_x10_device(config_data, new_x10): """Add a new X10 device to X10 device list.""" - curr_device = {} - x10_devices = config_data.get(CONF_X10, []) - for x10_device in x10_devices: + x10_devices = [] + for x10_device in config_data.get(CONF_X10, []): if ( - x10_device[CONF_HOUSECODE] == new_x10[CONF_HOUSECODE] - and x10_device[CONF_UNITCODE] == new_x10[CONF_UNITCODE] + x10_device[CONF_HOUSECODE] != new_x10[CONF_HOUSECODE] + or x10_device[CONF_UNITCODE] != new_x10[CONF_UNITCODE] ): - curr_device = x10_device - break - - if curr_device: - x10_devices.remove(curr_device) + x10_devices.append(x10_device) + curr_device = {} curr_device[CONF_HOUSECODE] = new_x10[CONF_HOUSECODE] curr_device[CONF_UNITCODE] = new_x10[CONF_UNITCODE] curr_device[CONF_PLATFORM] = new_x10[CONF_PLATFORM] curr_device[CONF_DIM_STEPS] = new_x10[CONF_DIM_STEPS] x10_devices.append(curr_device) - config_data[CONF_X10] = x10_devices - return config_data + + new_config = {} + if config_data.get(CONF_OVERRIDE): + new_config[CONF_OVERRIDE] = config_data[CONF_OVERRIDE] + new_config[CONF_X10] = x10_devices + return new_config def build_device_override_schema( diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index 43430ceb7a0..0a1a0253b1d 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -1,5 +1,6 @@ """Support for INSTEON dimmers via PowerLinc Modem.""" from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import SIGNAL_ADD_ENTITIES @@ -10,15 +11,16 @@ from .utils import async_add_insteon_entities async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Insteon switches from a config entry.""" - def add_entities(discovery_info=None): + @callback + def async_add_insteon_switch_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, SWITCH_DOMAIN, InsteonSwitchEntity, async_add_entities, discovery_info ) signal = f"{SIGNAL_ADD_ENTITIES}_{SWITCH_DOMAIN}" - async_dispatcher_connect(hass, signal, add_entities) - add_entities() + async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) + async_add_insteon_switch_entities() class InsteonSwitchEntity(InsteonEntity, SwitchEntity): diff --git a/homeassistant/components/insteon/translations/et.json b/homeassistant/components/insteon/translations/et.json index 5fee63e1219..69368300c7e 100644 --- a/homeassistant/components/insteon/translations/et.json +++ b/homeassistant/components/insteon/translations/et.json @@ -76,7 +76,7 @@ "port": "", "username": "Kasutajanimi" }, - "description": "Muutda Insteon Hubi \u00fchenduse teavet. P\u00e4rast selle muudatuse tegemist pead Home Assistanti taask\u00e4ivitama. See ei muuda jaoturi enda konfiguratsiooni. Hubis muudatuste tegemiseks kasutage rakendust Hub.", + "description": "Muutda Insteon Hubi \u00fchenduse teavet. P\u00e4rast selle muudatuse tegemist pead Home Assistanti taask\u00e4ivitama. See ei muuda jaoturi enda konfiguratsiooni. Hubis muudatuste tegemiseks kasuta rakendust Hub.", "title": "" }, "init": { diff --git a/homeassistant/components/insteon/translations/ko.json b/homeassistant/components/insteon/translations/ko.json index 76ef9566725..1cd65afc9e9 100644 --- a/homeassistant/components/insteon/translations/ko.json +++ b/homeassistant/components/insteon/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub428. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "select_single": "\ud558\ub098\uc758 \uc635\uc158\uc744 \uc120\ud0dd\ud558\uc2ed\uc2dc\uc624." }, "step": { @@ -20,9 +20,9 @@ "hubv2": { "data": { "host": "IP \uc8fc\uc18c", - "password": "\uc554\ud638", + "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "username": "\uc0ac\uc6a9\uc790\uba85" + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, "description": "Insteon Hub \ubc84\uc804 2\ub97c \uad6c\uc131\ud569\ub2c8\ub2e4.", "title": "Insteon Hub \ubc84\uc804 2" @@ -43,7 +43,7 @@ }, "options": { "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "select_single": "\uc635\uc158 \uc120\ud0dd" }, "step": { @@ -59,6 +59,14 @@ }, "description": "Insteon Hub \ube44\ubc00\ubc88\ud638\ub97c \ubcc0\uacbd\ud569\ub2c8\ub2e4." }, + "change_hub_config": { + "data": { + "host": "IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, "init": { "data": { "add_override": "\uc7a5\uce58 Override \ucd94\uac00", diff --git a/homeassistant/components/insteon/translations/nl.json b/homeassistant/components/insteon/translations/nl.json index d2f73fca37b..98a27fb1139 100644 --- a/homeassistant/components/insteon/translations/nl.json +++ b/homeassistant/components/insteon/translations/nl.json @@ -28,6 +28,9 @@ "title": "Insteon Hub versie 2" }, "plm": { + "data": { + "device": "USB-apparaatpad" + }, "description": "Configureer de Insteon PowerLink Modem (PLM).", "title": "Insteon PLM" }, @@ -84,6 +87,9 @@ "remove_override": "Verwijder een apparaatoverschrijving.", "remove_x10": "Verwijder een X10-apparaat." } + }, + "remove_x10": { + "title": "Insteon" } } } diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index a776920b8e6..6c59035adb4 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + CONF_METHOD, CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -31,7 +32,6 @@ CONF_ROUND_DIGITS = "round" CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" CONF_UNIT_OF_MEASUREMENT = "unit" -CONF_METHOD = "method" TRAPEZOIDAL_METHOD = "trapezoidal" LEFT_METHOD = "left" diff --git a/homeassistant/components/ios/translations/ko.json b/homeassistant/components/ios/translations/ko.json index 6abe9380473..f5da462c1ab 100644 --- a/homeassistant/components/ios/translations/ko.json +++ b/homeassistant/components/ios/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 Home Assistant iOS \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { - "description": "Home Assistant iOS \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" } } } diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index 04064db2b88..47434d7f76b 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -1,6 +1,4 @@ """Constants for IPMA component.""" -import logging - from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN DOMAIN = "ipma" @@ -8,5 +6,3 @@ DOMAIN = "ipma" HOME_LOCATION_NAME = "Home" ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" - -_LOGGER = logging.getLogger(".") diff --git a/homeassistant/components/ipma/translations/fr.json b/homeassistant/components/ipma/translations/fr.json index 9a3a11a7a73..eaff9d211db 100644 --- a/homeassistant/components/ipma/translations/fr.json +++ b/homeassistant/components/ipma/translations/fr.json @@ -15,5 +15,10 @@ "title": "Emplacement" } } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Point de terminaison de l'API IPMA accessible" + } } } \ No newline at end of file diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index feed7e7b528..3815dcf8f69 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -106,7 +106,6 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): tls = zctype == "_ipps._tcp.local." base_path = discovery_info["properties"].get("rp", "ipp/print") - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": name}}) self.discovery_info.update( diff --git a/homeassistant/components/ipp/translations/et.json b/homeassistant/components/ipp/translations/et.json index 622b71d758a..5a0a2e69cfb 100644 --- a/homeassistant/components/ipp/translations/et.json +++ b/homeassistant/components/ipp/translations/et.json @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "\u00dchendamine nurjus", - "connection_upgrade": "Printeriga \u00fchenduse loomine nurjus. Proovige uuesti kui SSL/TLS-i suvand on m\u00e4rgitud." + "connection_upgrade": "Printeriga \u00fchenduse loomine nurjus. Proovi uuesti kui SSL/TLS-i suvand on m\u00e4rgitud." }, "flow_title": "Printer: {name}", "step": { @@ -23,8 +23,8 @@ "ssl": "Printer toetab SSL/TLS \u00fchendust", "verify_ssl": "Printer kasutab \u00f5iget SSL-serti" }, - "description": "Seadistage oma printer Interneti-printimisprotokolli (IPP) kaudu, et see integreeruks Home Assistantiga.", - "title": "Linkige oma printer" + "description": "Seadista oma printer Interneti-printimisprotokolli (IPP) kaudu, et see integreeruks Home Assistantiga.", + "title": "Lingi oma printer" }, "zeroconf_confirm": { "description": "Kas soovite seadistada {name}?", diff --git a/homeassistant/components/ipp/translations/ko.json b/homeassistant/components/ipp/translations/ko.json index bc2e18cb5c2..28e79ffe281 100644 --- a/homeassistant/components/ipp/translations/ko.json +++ b/homeassistant/components/ipp/translations/ko.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud558\ub824\uba74 \uc5f0\uacb0\uc744 \uc5c5\uadf8\ub808\uc774\ub4dc\ud574\uc57c \ud569\ub2c8\ub2e4.", "ipp_error": "IPP \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "ipp_version_error": "\ud504\ub9b0\ud130\uc5d0\uc11c IPP \ubc84\uc804\uc744 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", @@ -9,6 +10,7 @@ "unique_id_required": "\uae30\uae30 \uac80\uc0c9\uc5d0 \ud544\uc694\ud55c \uace0\uc720\ud55c ID \uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. SSL/TLS \uc635\uc158\uc744 \ud655\uc778\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." }, "flow_title": "\ud504\ub9b0\ud130: {name}", @@ -18,8 +20,8 @@ "base_path": "\ud504\ub9b0\ud130\uc758 \uc0c1\ub300 \uacbd\ub85c", "host": "\ud638\uc2a4\ud2b8", "port": "\ud3ec\ud2b8", - "ssl": "\ud504\ub9b0\ud130\ub294 SSL/TLS \ub97c \ud1b5\ud55c \ud1b5\uc2e0\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4", - "verify_ssl": "\ud504\ub9b0\ud130\ub294 \uc62c\ubc14\ub978 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "description": "\uc778\ud130\ub137 \uc778\uc1c4 \ud504\ub85c\ud1a0\ucf5c (IPP) \ub97c \ud1b5\ud574 \ud504\ub9b0\ud130\ub97c \uc124\uc815\ud558\uc5ec Home Assistant \uc640 \uc5f0\ub3d9\ud569\ub2c8\ub2e4.", "title": "\ud504\ub9b0\ud130 \uc5f0\uacb0\ud558\uae30" diff --git a/homeassistant/components/iqvia/translations/ko.json b/homeassistant/components/iqvia/translations/ko.json index f6a914bd07d..1b0dfd980ec 100644 --- a/homeassistant/components/iqvia/translations/ko.json +++ b/homeassistant/components/iqvia/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc6b0\ud3b8 \ubc88\ud638\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "invalid_zip_code": "\uc6b0\ud3b8\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/islamic_prayer_times/translations/ko.json b/homeassistant/components/islamic_prayer_times/translations/ko.json index 52ac6869855..240ad6f57dc 100644 --- a/homeassistant/components/islamic_prayer_times/translations/ko.json +++ b/homeassistant/components/islamic_prayer_times/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, "step": { "user": { "description": "\uc774\uc2ac\ub78c \uae30\ub3c4 \uc2dc\uac04\uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 6049b8c6ec3..3d52687bced 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -168,7 +168,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_HOST: url, } - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 95bd43facde..a484b56b145 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -100,6 +100,8 @@ class ISYEntity(Entity): f"ProductID:{node.zwave_props.product_id}" ) # Note: sw_version is not exposed by the ISY for the individual devices. + if hasattr(node, "folder") and node.folder is not None: + device_info["suggested_area"] = node.folder return device_info diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 384ff22403a..f565383f007 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -1,36 +1,23 @@ """Support for ISY994 fans.""" +import math from typing import Callable -from pyisy.constants import ISY_VALUE_UNKNOWN +from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_INSTEON -from homeassistant.components.fan import ( - DOMAIN as FAN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import DOMAIN as FAN, SUPPORT_SET_SPEED, FanEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS from .entity import ISYNodeEntity, ISYProgramEntity from .helpers import migrate_old_unique_ids -VALUE_TO_STATE = { - 0: SPEED_OFF, - 63: SPEED_LOW, - 64: SPEED_LOW, - 190: SPEED_MEDIUM, - 191: SPEED_MEDIUM, - 255: SPEED_HIGH, -} - -STATE_TO_VALUE = {} -for key in VALUE_TO_STATE: - STATE_TO_VALUE[VALUE_TO_STATE[key]] = key +SPEED_RANGE = (1, 255) # off is not included async def async_setup_entry( @@ -56,9 +43,18 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): """Representation of an ISY994 fan device.""" @property - def speed(self) -> str: - """Return the current speed.""" - return VALUE_TO_STATE.get(self._node.status) + def percentage(self) -> str: + """Return the current speed percentage.""" + if self._node.status == ISY_VALUE_UNKNOWN: + return None + return ranged_value_to_percentage(SPEED_RANGE, self._node.status) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._node.protocol == PROTO_INSTEON: + return 3 + return int_states_in_range(SPEED_RANGE) @property def is_on(self) -> bool: @@ -67,23 +63,30 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): return None return self._node.status != 0 - def set_speed(self, speed: str) -> None: - """Send the set speed command to the ISY994 fan device.""" - self._node.turn_on(val=STATE_TO_VALUE.get(speed, 255)) + def set_percentage(self, percentage: int) -> None: + """Set node to speed percentage for the ISY994 fan device.""" + if percentage == 0: + self._node.turn_off() + return - def turn_on(self, speed: str = None, **kwargs) -> None: + isy_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + + self._node.turn_on(val=isy_speed) + + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Send the turn on command to the ISY994 fan device.""" - self.set_speed(speed) + self.set_percentage(percentage) def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 fan device.""" self._node.turn_off() - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - @property def supported_features(self) -> int: """Flag supported features.""" @@ -94,9 +97,18 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity): """Representation of an ISY994 fan program.""" @property - def speed(self) -> str: - """Return the current speed.""" - return VALUE_TO_STATE.get(self._node.status) + def percentage(self) -> str: + """Return the current speed percentage.""" + if self._node.status == ISY_VALUE_UNKNOWN: + return None + return ranged_value_to_percentage(SPEED_RANGE, self._node.status) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._node.protocol == PROTO_INSTEON: + return 3 + return int_states_in_range(SPEED_RANGE) @property def is_on(self) -> bool: @@ -108,7 +120,13 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity): if not self._actions.run_then(): _LOGGER.error("Unable to turn off the fan") - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.run_else(): _LOGGER.error("Unable to turn on the fan") diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 9e22b3533d7..3769cc328db 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -2,7 +2,7 @@ "domain": "isy994", "name": "Universal Devices ISY994", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==2.1.0"], + "requirements": ["pyisy==2.1.1"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/translations/ru.json b/homeassistant/components/isy994/translations/ru.json index c0a658423c6..cbf88574e1e 100644 --- a/homeassistant/components/isy994/translations/ru.json +++ b/homeassistant/components/isy994/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_host": "URL-\u0430\u0434\u0440\u0435\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0444\u043e\u0440\u043c\u0430\u0442\u0435 'address[:port]' (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: 'http://192.168.10.100:80').", "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/izone/climate.py b/homeassistant/components/izone/climate.py index 443a80298f1..776d3f120c9 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -110,6 +110,8 @@ class ControllerDevice(ClimateEntity): self._supported_features = SUPPORT_FAN_MODE + # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, + # otherwise the unit determines which zone to use as target. See interface manual p. 8 if ( controller.ras_mode == "master" and controller.zone_ctrl == 13 ) or controller.ras_mode == "RAS": @@ -269,6 +271,16 @@ class ControllerDevice(ClimateEntity): self.temperature_unit, PRECISION_HALVES, ), + "control_zone": self._controller.zone_ctrl, + "control_zone_name": self.control_zone_name, + # Feature SUPPORT_TARGET_TEMPERATURE controls both displaying target temp & setting it + # As the feature is turned off for zone control, report target temp as extra state attribute + "control_zone_setpoint": show_temp( + self.hass, + self.control_zone_setpoint, + self.temperature_unit, + PRECISION_HALVES, + ), } @property @@ -314,13 +326,35 @@ class ControllerDevice(ClimateEntity): return self._controller.temp_supply return self._controller.temp_return + @property + def control_zone_name(self): + """Return the zone that currently controls the AC unit (if target temp not set by controller).""" + if self._supported_features & SUPPORT_TARGET_TEMPERATURE: + return None + zone_ctrl = self._controller.zone_ctrl + zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None) + if zone is None: + return None + return zone.name + + @property + def control_zone_setpoint(self) -> Optional[float]: + """Return the temperature setpoint of the zone that currently controls the AC unit (if target temp not set by controller).""" + if self._supported_features & SUPPORT_TARGET_TEMPERATURE: + return None + zone_ctrl = self._controller.zone_ctrl + zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None) + if zone is None: + return None + return zone.target_temperature + @property @_return_on_connection_error() def target_temperature(self) -> Optional[float]: - """Return the temperature we try to reach.""" - if not self._supported_features & SUPPORT_TARGET_TEMPERATURE: - return None - return self._controller.temp_setpoint + """Return the temperature we try to reach (either from control zone or master unit).""" + if self._supported_features & SUPPORT_TARGET_TEMPERATURE: + return self._controller.temp_setpoint + return self.control_zone_setpoint @property def supply_temperature(self) -> float: @@ -569,3 +603,15 @@ class ZoneDevice(ClimateEntity): """Turn device off (close zone).""" await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.CLOSE)) self.async_write_ha_state() + + @property + def zone_index(self): + """Return the zone index for matching to CtrlZone.""" + return self._zone.index + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + return { + "zone_index": self.zone_index, + } diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 7690600786e..2a4ad516af1 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,7 +1,4 @@ """Internal discovery service for iZone AC.""" - -import logging - import pizone from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -18,8 +15,6 @@ from .const import ( DISPATCH_ZONE_UPDATE, ) -_LOGGER = logging.getLogger(__name__) - class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" diff --git a/homeassistant/components/izone/translations/ko.json b/homeassistant/components/izone/translations/ko.json index 85aec276562..b6eae170bec 100644 --- a/homeassistant/components/izone/translations/ko.json +++ b/homeassistant/components/izone/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "iZone \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 iZone \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py index 1bc4ae298c4..a65a7ffd7fe 100644 --- a/homeassistant/components/joaoapps_join/__init__.py +++ b/homeassistant/components/joaoapps_join/__init__.py @@ -12,14 +12,13 @@ from pyjoin import ( ) import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DOMAIN = "joaoapps_join" -CONF_DEVICE_ID = "device_id" CONF_DEVICE_IDS = "device_ids" CONF_DEVICE_NAMES = "device_names" @@ -115,7 +114,6 @@ def register_device(hass, api_key, name, device_id, device_ids, device_names): def setup(hass, config): """Set up the Join services.""" - for device in config[DOMAIN]: api_key = device.get(CONF_API_KEY) device_id = device.get(CONF_DEVICE_ID) diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py index d01e49c77d8..7ba089e5dab 100644 --- a/homeassistant/components/joaoapps_join/notify.py +++ b/homeassistant/components/joaoapps_join/notify.py @@ -11,12 +11,11 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_DEVICE_ID = "device_id" CONF_DEVICE_IDS = "device_ids" CONF_DEVICE_NAMES = "device_names" @@ -61,7 +60,6 @@ class JoinNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) or {} send_notification( diff --git a/homeassistant/components/juicenet/translations/ko.json b/homeassistant/components/juicenet/translations/ko.json index 50b824ec82f..1e1ae6aaa88 100644 --- a/homeassistant/components/juicenet/translations/ko.json +++ b/homeassistant/components/juicenet/translations/ko.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "\uc774 JuiceNet \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { "data": { - "api_token": "JuiceNet API \ud1a0\ud070" + "api_token": "API \ud1a0\ud070" }, "description": "https://home.juice.net/Manage \uc758 API \ud1a0\ud070\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.", "title": "JuiceNet \uc5d0 \uc5f0\uacb0\ud558\uae30" diff --git a/homeassistant/components/juicenet/translations/ru.json b/homeassistant/components/juicenet/translations/ru.json index 2fec7d485c4..d582e6f1703 100644 --- a/homeassistant/components/juicenet/translations/ru.json +++ b/homeassistant/components/juicenet/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/juicenet/translations/zh-Hant.json b/homeassistant/components/juicenet/translations/zh-Hant.json index 815edb1fb27..f310babfd80 100644 --- a/homeassistant/components/juicenet/translations/zh-Hant.json +++ b/homeassistant/components/juicenet/translations/zh-Hant.json @@ -11,9 +11,9 @@ "step": { "user": { "data": { - "api_token": "API \u5bc6\u9470" + "api_token": "API \u6b0a\u6756" }, - "description": "\u5c07\u9700\u8981\u7531 https://home.juice.net/Manage \u53d6\u5f97 API \u5bc6\u9470\u3002", + "description": "\u5c07\u9700\u8981\u7531 https://home.juice.net/Manage \u53d6\u5f97 API \u6b0a\u6756\u3002", "title": "\u9023\u7dda\u81f3 JuiceNet" } } diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index cb0a718d716..42d747b5238 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -1 +1,92 @@ """The keenetic_ndms2 component.""" + +from homeassistant.components import binary_sensor, device_tracker +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import Config, HomeAssistant + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ROUTER, + UNDO_UPDATE_LISTENER, +) +from .router import KeeneticRouter + +PLATFORMS = [device_tracker.DOMAIN, binary_sensor.DOMAIN] + + +async def async_setup(hass: HomeAssistant, _config: Config) -> bool: + """Set up configured entries.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the component.""" + + async_add_defaults(hass, config_entry) + + router = KeeneticRouter(hass, config_entry) + await router.async_setup() + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data[DOMAIN][config_entry.entry_id] = { + ROUTER: router, + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + for component in PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, component) + + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + + await router.async_teardown() + + hass.data[DOMAIN].pop(config_entry.entry_id) + + return True + + +async def update_listener(hass, config_entry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +def async_add_defaults(hass: HomeAssistant, config_entry: ConfigEntry): + """Populate default options.""" + host: str = config_entry.data[CONF_HOST] + imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {}) + options = { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME, + CONF_INTERFACES: [DEFAULT_INTERFACE], + CONF_TRY_HOTSPOT: True, + CONF_INCLUDE_ARP: True, + CONF_INCLUDE_ASSOCIATED: True, + **imported_options, + **config_entry.options, + } + + if options.keys() - config_entry.options.keys(): + hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py new file mode 100644 index 00000000000..5da52eff00d --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -0,0 +1,72 @@ +"""The Keenetic Client class.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import KeeneticRouter +from .const import DOMAIN, ROUTER + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up device tracker for Keenetic NDMS2 component.""" + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + + async_add_entities([RouterOnlineBinarySensor(router)]) + + +class RouterOnlineBinarySensor(BinarySensorEntity): + """Representation router connection status.""" + + def __init__(self, router: KeeneticRouter): + """Initialize the APCUPSd binary device.""" + self._router = router + + @property + def name(self): + """Return the name of the online status sensor.""" + return f"{self._router.name} Online" + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return f"online_{self._router.config_entry.entry_id}" + + @property + def is_on(self): + """Return true if the UPS is online, else false.""" + return self._router.available + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_CONNECTIVITY + + @property + def should_poll(self) -> bool: + """Return False since entity pushes its state to HA.""" + return False + + @property + def device_info(self): + """Return a client description for device registry.""" + return self._router.device_info + + async def async_added_to_hass(self): + """Client entity created.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_update, + self.async_write_ha_state, + ) + ) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py new file mode 100644 index 00000000000..9338cb05935 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -0,0 +1,159 @@ +"""Config flow for Keenetic NDMS2.""" +from typing import List + +from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TELNET_PORT, + DOMAIN, + ROUTER, +) +from .router import KeeneticRouter + + +class KeeneticFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return KeeneticOptionsFlowHandler(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: + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + return self.async_abort(reason="already_configured") + + _client = Client( + TelnetConnection( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + timeout=10, + ) + ) + + try: + router_info = await self.hass.async_add_executor_job( + _client.get_router_info + ) + except ConnectionException: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=router_info.name, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_TELNET_PORT): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + +class KeeneticOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self._interface_options = {} + + async def async_step_init(self, _user_input=None): + """Manage the options.""" + router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][ + ROUTER + ] + + interfaces: List[InterfaceInfo] = await self.hass.async_add_executor_job( + router.client.get_interfaces + ) + + self._interface_options = { + interface.name: (interface.description or interface.name) + for interface in interfaces + if interface.type.lower() == "bridge" + } + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + vol.Required( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ), + ): int, + vol.Required( + CONF_INTERFACES, + default=self.config_entry.options.get( + CONF_INTERFACES, [DEFAULT_INTERFACE] + ), + ): cv.multi_select(self._interface_options), + vol.Optional( + CONF_TRY_HOTSPOT, + default=self.config_entry.options.get(CONF_TRY_HOTSPOT, True), + ): bool, + vol.Optional( + CONF_INCLUDE_ARP, + default=self.config_entry.options.get(CONF_INCLUDE_ARP, True), + ): bool, + vol.Optional( + CONF_INCLUDE_ASSOCIATED, + default=self.config_entry.options.get( + CONF_INCLUDE_ASSOCIATED, True + ), + ): bool, + } + ) + + return self.async_show_form(step_id="user", data_schema=options) diff --git a/homeassistant/components/keenetic_ndms2/const.py b/homeassistant/components/keenetic_ndms2/const.py new file mode 100644 index 00000000000..1818cfab6a6 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/const.py @@ -0,0 +1,21 @@ +"""Constants used in the Keenetic NDMS2 components.""" + +from homeassistant.components.device_tracker.const import ( + DEFAULT_CONSIDER_HOME as _DEFAULT_CONSIDER_HOME, +) + +DOMAIN = "keenetic_ndms2" +ROUTER = "router" +UNDO_UPDATE_LISTENER = "undo_update_listener" +DEFAULT_TELNET_PORT = 23 +DEFAULT_SCAN_INTERVAL = 120 +DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.seconds +DEFAULT_INTERFACE = "Home" + +CONF_CONSIDER_HOME = "consider_home" +CONF_INTERFACES = "interfaces" +CONF_TRY_HOTSPOT = "try_hotspot" +CONF_INCLUDE_ARP = "include_arp" +CONF_INCLUDE_ASSOCIATED = "include_associated" + +CONF_LEGACY_INTERFACE = "interface" diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index d98806dfc05..9df222a326c 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -1,102 +1,253 @@ -"""Support for Zyxel Keenetic NDMS2 based routers.""" +"""Support for Keenetic routers as device tracker.""" +from datetime import timedelta import logging +from typing import List, Optional, Set -from ndms2_client import Client, ConnectionException, TelnetConnection +from ndms2_client import Device import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, - PLATFORM_SCHEMA, - DeviceScanner, + DOMAIN as DEVICE_TRACKER_DOMAIN, + PLATFORM_SCHEMA as DEVICE_TRACKER_SCHEMA, + SOURCE_TYPE_ROUTER, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INTERFACES, + CONF_LEGACY_INTERFACE, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TELNET_PORT, + DOMAIN, + ROUTER, +) +from .router import KeeneticRouter _LOGGER = logging.getLogger(__name__) -# Interface name to track devices for. Most likely one will not need to -# change it from default 'Home'. This is needed not to track Guest WI-FI- -# clients and router itself -CONF_INTERFACE = "interface" - -DEFAULT_INTERFACE = "Home" -DEFAULT_PORT = 23 - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = DEVICE_TRACKER_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_PORT, default=DEFAULT_TELNET_PORT): cv.port, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Required(CONF_LEGACY_INTERFACE, default=DEFAULT_INTERFACE): cv.string, } ) -def get_scanner(_hass, config): - """Validate the configuration and return a Keenetic NDMS2 scanner.""" - scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) +async def async_get_scanner(hass: HomeAssistant, config): + """Import legacy configuration from YAML.""" - return scanner if scanner.success_init else None + scanner_config = config[DEVICE_TRACKER_DOMAIN] + scan_interval: Optional[timedelta] = scanner_config.get(CONF_SCAN_INTERVAL) + consider_home: Optional[timedelta] = scanner_config.get(CONF_CONSIDER_HOME) + + host: str = scanner_config[CONF_HOST] + hass.data[DOMAIN][f"imported_options_{host}"] = { + CONF_INTERFACES: [scanner_config[CONF_LEGACY_INTERFACE]], + CONF_SCAN_INTERVAL: int(scan_interval.total_seconds()) + if scan_interval + else DEFAULT_SCAN_INTERVAL, + CONF_CONSIDER_HOME: int(consider_home.total_seconds()) + if consider_home + else DEFAULT_CONSIDER_HOME, + } + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: scanner_config[CONF_HOST], + CONF_PORT: scanner_config[CONF_PORT], + CONF_USERNAME: scanner_config[CONF_USERNAME], + CONF_PASSWORD: scanner_config[CONF_PASSWORD], + }, + ) + ) + + _LOGGER.warning( + "Your Keenetic NDMS2 configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + "Loading Keenetic NDMS2 via scanner setup is now deprecated" + ) + + return None -class KeeneticNDMS2DeviceScanner(DeviceScanner): - """This class scans for devices using keenetic NDMS2 web interface.""" +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up device tracker for Keenetic NDMS2 component.""" + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - def __init__(self, config): - """Initialize the scanner.""" + tracked = set() - self.last_results = [] + @callback + def update_from_router(): + """Update the status of devices.""" + update_items(router, async_add_entities, tracked) - self._interface = config[CONF_INTERFACE] + update_from_router() - self._client = Client( - TelnetConnection( - config.get(CONF_HOST), - config.get(CONF_PORT), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), + registry = await entity_registry.async_get_registry(hass) + # Restore devices that are not a part of active clients list. + restored = [] + for entity_entry in registry.entities.values(): + if ( + entity_entry.config_entry_id == config_entry.entry_id + and entity_entry.domain == DEVICE_TRACKER_DOMAIN + ): + mac = entity_entry.unique_id.partition("_")[0] + if mac not in tracked: + tracked.add(mac) + restored.append( + KeeneticTracker( + Device( + mac=mac, + # restore the original name as set by the router before + name=entity_entry.original_name, + ip=None, + interface=None, + ), + router, + ) + ) + + if restored: + async_add_entities(restored) + + async_dispatcher_connect(hass, router.signal_update, update_from_router) + + +@callback +def update_items(router: KeeneticRouter, async_add_entities, tracked: Set[str]): + """Update tracked device state from the hub.""" + new_tracked: List[KeeneticTracker] = [] + for mac, device in router.last_devices.items(): + if mac not in tracked: + tracked.add(mac) + new_tracked.append(KeeneticTracker(device, router)) + + if new_tracked: + async_add_entities(new_tracked) + + +class KeeneticTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device: Device, router: KeeneticRouter): + """Initialize the tracked device.""" + self._device = device + self._router = router + self._last_seen = ( + dt_util.utcnow() if device.mac in router.last_devices else None + ) + + @property + def should_poll(self) -> bool: + """Return False since entity pushes its state to HA.""" + return False + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return ( + self._last_seen + and (dt_util.utcnow() - self._last_seen) + < self._router.consider_home_interval + ) + + @property + def source_type(self): + """Return the source type of the client.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device.name or self._device.mac + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return f"{self._device.mac}_{self._router.config_entry.entry_id}" + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._device.ip if self.is_connected else None + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self._router.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return { + "interface": self._device.interface, + } + return None + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, + "identifiers": {(DOMAIN, self._device.mac)}, + } + + if self._device.name: + info["name"] = self._device.name + + return info + + async def async_added_to_hass(self): + """Client entity created.""" + _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) + + @callback + def update_device(): + _LOGGER.debug( + "Updating Keenetic tracked device %s (%s)", + self.entity_id, + self.unique_id, + ) + new_device = self._router.last_devices.get(self._device.mac) + if new_device: + self._device = new_device + self._last_seen = dt_util.utcnow() + + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, self._router.signal_update, update_device ) ) - - self.success_init = self._update_info() - _LOGGER.info("Scanner initialized") - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return [device.mac for device in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - name = next( - (result.name for result in self.last_results if result.mac == device), None - ) - return name - - def get_extra_attributes(self, device): - """Return the IP of the given device.""" - attributes = next( - ({"ip": result.ip} for result in self.last_results if result.mac == device), - {}, - ) - return attributes - - def _update_info(self): - """Get ARP from keenetic router.""" - _LOGGER.debug("Fetching devices from router...") - - try: - self.last_results = [ - dev - for dev in self._client.get_devices() - if dev.interface == self._interface - ] - _LOGGER.debug("Successfully fetched data from router") - return True - - except ConnectionException: - _LOGGER.error("Error fetching data from router") - return False diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 9d4c9f35716..da8321a8bdc 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -1,7 +1,8 @@ { "domain": "keenetic_ndms2", - "name": "Keenetic NDMS2 Routers", + "name": "Keenetic NDMS2 Router", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", - "requirements": ["ndms2_client==0.0.11"], + "requirements": ["ndms2_client==0.1.1"], "codeowners": ["@foxel"] } diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py new file mode 100644 index 00000000000..340b25ff725 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -0,0 +1,187 @@ +"""The Keenetic Client class.""" +from datetime import timedelta +import logging +from typing import Callable, Dict, Optional + +from ndms2_client import Client, ConnectionException, Device, TelnetConnection +from ndms2_client.client import RouterInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class KeeneticRouter: + """Keenetic client Object.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + """Initialize the Client.""" + self.hass = hass + self.config_entry = config_entry + self._last_devices: Dict[str, Device] = {} + self._router_info: Optional[RouterInfo] = None + self._connection: Optional[TelnetConnection] = None + self._client: Optional[Client] = None + self._cancel_periodic_update: Optional[Callable] = None + self._available = False + self._progress = None + + @property + def client(self): + """Read-only accessor for the client connection.""" + return self._client + + @property + def last_devices(self): + """Read-only accessor for last_devices.""" + return self._last_devices + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def device_info(self): + """Return the host of this hub.""" + return { + "identifiers": {(DOMAIN, f"router-{self.config_entry.entry_id}")}, + "manufacturer": self.manufacturer, + "model": self.model, + "name": self.name, + "sw_version": self.firmware, + } + + @property + def name(self): + """Return the name of the hub.""" + return self._router_info.name if self._router_info else self.host + + @property + def model(self): + """Return the model of the hub.""" + return self._router_info.model if self._router_info else None + + @property + def firmware(self): + """Return the firmware of the hub.""" + return self._router_info.fw_version if self._router_info else None + + @property + def manufacturer(self): + """Return the firmware of the hub.""" + return self._router_info.manufacturer if self._router_info else None + + @property + def available(self): + """Return if the hub is connected.""" + return self._available + + @property + def consider_home_interval(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_CONSIDER_HOME]) + + @property + def signal_update(self): + """Event specific per router entry to signal updates.""" + return f"keenetic-update-{self.config_entry.entry_id}" + + async def request_update(self): + """Request an update.""" + if self._progress is not None: + await self._progress + return + + self._progress = self.hass.async_create_task(self.async_update()) + await self._progress + + self._progress = None + + async def async_update(self): + """Update devices information.""" + await self.hass.async_add_executor_job(self._update_devices) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the connection.""" + self._connection = TelnetConnection( + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_PORT], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + self._client = Client(self._connection) + + try: + await self.hass.async_add_executor_job(self._update_router_info) + except ConnectionException as error: + raise ConfigEntryNotReady from error + + async def async_update_data(_now): + await self.request_update() + self._cancel_periodic_update = async_call_later( + self.hass, + self.config_entry.options[CONF_SCAN_INTERVAL], + async_update_data, + ) + + await async_update_data(dt_util.utcnow()) + + async def async_teardown(self): + """Teardown up the connection.""" + if self._cancel_periodic_update: + self._cancel_periodic_update() + self._connection.disconnect() + + def _update_router_info(self): + try: + self._router_info = self._client.get_router_info() + self._available = True + except Exception: + self._available = False + raise + + def _update_devices(self): + """Get ARP from keenetic router.""" + _LOGGER.debug("Fetching devices from router...") + + try: + _response = self._client.get_devices( + try_hotspot=self.config_entry.options[CONF_TRY_HOTSPOT], + include_arp=self.config_entry.options[CONF_INCLUDE_ARP], + include_associated=self.config_entry.options[CONF_INCLUDE_ASSOCIATED], + ) + self._last_devices = { + dev.mac: dev + for dev in _response + if dev.interface in self.config_entry.options[CONF_INTERFACES] + } + _LOGGER.debug("Successfully fetched data from router: %s", str(_response)) + self._router_info = self._client.get_router_info() + self._available = True + + except ConnectionException: + _LOGGER.error("Error fetching data from router") + self._available = False diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json new file mode 100644 index 00000000000..15629ba0f2f --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Keenetic NDMS2 Router", + "data": { + "name": "Name", + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "Scan interval", + "consider_home": "Consider home interval", + "interfaces": "Choose interfaces to scan", + "try_hotspot": "Use 'ip hotspot' data (most accurate)", + "include_arp": "Use ARP data (ignored if hotspot data used)", + "include_associated": "Use WiFi AP associations data (ignored if hotspot data used)" + } + } + } + } +} diff --git a/homeassistant/components/keenetic_ndms2/translations/ca.json b/homeassistant/components/keenetic_ndms2/translations/ca.json new file mode 100644 index 00000000000..f15b11b3eb4 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + }, + "title": "Configuraci\u00f3 del router Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Interval per considerar a casa", + "include_arp": "Utilitza dades d'ARP (s'ignorar\u00e0 si s'utilitzen dades de 'punt d'acc\u00e9s')", + "include_associated": "Utilitza dades d'associacions d'AP WiFi (s'ignorar\u00e0 si s'utilitzen dades de 'punt d'acc\u00e9s')", + "interfaces": "Escull les interf\u00edcies a escanejar", + "scan_interval": "Interval d'escaneig", + "try_hotspot": "Utilitza dades de 'punt d'acc\u00e9s IP' (m\u00e9s precisi\u00f3)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/cs.json b/homeassistant/components/keenetic_ndms2/translations/cs.json new file mode 100644 index 00000000000..f34807f3fee --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "N\u00e1zev", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/de.json b/homeassistant/components/keenetic_ndms2/translations/de.json new file mode 100644 index 00000000000..71ce0154639 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/en.json b/homeassistant/components/keenetic_ndms2/translations/en.json new file mode 100644 index 00000000000..e95f2f740ef --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "title": "Set up Keenetic NDMS2 Router" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Consider home interval", + "include_arp": "Use ARP data (ignored if hotspot data used)", + "include_associated": "Use WiFi AP associations data (ignored if hotspot data used)", + "interfaces": "Choose interfaces to scan", + "scan_interval": "Scan interval", + "try_hotspot": "Use 'ip hotspot' data (most accurate)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/es.json b/homeassistant/components/keenetic_ndms2/translations/es.json new file mode 100644 index 00000000000..6846cfbef42 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/es.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "Fallo de conexi\u00f3n" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, + "title": "Configurar el router Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Considerar el intervalo en casa", + "include_arp": "Usar datos ARP (ignorado si se usan datos de hotspot)", + "include_associated": "Utilizar los datos de las asociaciones WiFi AP (se ignora si se utilizan los datos del hotspot)", + "interfaces": "Elija las interfaces para escanear", + "scan_interval": "Intervalo de escaneo", + "try_hotspot": "Utilizar datos de 'punto de acceso ip' (m\u00e1s precisos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/et.json b/homeassistant/components/keenetic_ndms2/translations/et.json new file mode 100644 index 00000000000..dc500be7e1a --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/et.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi", + "password": "Salas\u00f5na", + "port": "Port", + "username": "Kasutajanimi" + }, + "title": "Seadista Keenetic NDMS2 ruuter" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "M\u00e4\u00e4ra n\u00e4htavuse aeg", + "include_arp": "Kasuta ARP andmeid (ignoreeritakse, kui kasutatakse kuumkoha andmeid)", + "include_associated": "Kasuta WiFi AP seoste andmeid (ignoreeritakse kui kasutatakse kuumkoha andmeid)", + "interfaces": "Sk\u00e4nnitavate liideste valimine", + "scan_interval": "P\u00e4ringute intervall", + "try_hotspot": "Kasuta 'ip hotspot' andmeid (k\u00f5ige t\u00e4psem)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/fr.json b/homeassistant/components/keenetic_ndms2/translations/fr.json new file mode 100644 index 00000000000..2ac19dcdc64 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "title": "Configurer le routeur Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Consid\u00e9rez l'intervalle de home assistant", + "include_arp": "Utiliser les donn\u00e9es ARP (ignor\u00e9es si les donn\u00e9es du hotspot sont utilis\u00e9es)", + "include_associated": "Utiliser les donn\u00e9es d'associations WiFi AP (ignor\u00e9es si les donn\u00e9es du hotspot sont utilis\u00e9es)", + "interfaces": "Choisissez les interfaces \u00e0 analyser", + "scan_interval": "Intervalle d\u2019analyse", + "try_hotspot": "Utiliser les donn\u00e9es 'ip hotspot' (plus pr\u00e9cis)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/it.json b/homeassistant/components/keenetic_ndms2/translations/it.json new file mode 100644 index 00000000000..e5a705d14b8 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome", + "password": "Password", + "port": "Porta", + "username": "Nome utente" + }, + "title": "Configurare il router Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Considerare in casa nell'intervallo di", + "include_arp": "Usa i dati ARP (ignorati se vengono utilizzati i dati dell'hotspot)", + "include_associated": "Usa i dati delle associazioni WiFi AP (ignorati se si usano i dati dell'hotspot)", + "interfaces": "Scegli le interfacce da scansionare", + "scan_interval": "Intervallo di scansione", + "try_hotspot": "Utilizza i dati \"ip hotspot\" (pi\u00f9 accurato)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/ko.json b/homeassistant/components/keenetic_ndms2/translations/ko.json new file mode 100644 index 00000000000..3281ddbe3d4 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/ko.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "\uc2a4\uce94 \uac04\uaca9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/nl.json b/homeassistant/components/keenetic_ndms2/translations/nl.json new file mode 100644 index 00000000000..c3c08575052 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "interfaces": "Kies interfaces om te scannen", + "scan_interval": "Scaninterval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/no.json b/homeassistant/components/keenetic_ndms2/translations/no.json new file mode 100644 index 00000000000..6ad2805eb3d --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn", + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + }, + "title": "Sett opp Keenetic NDMS2 Router" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Vurder hjemmeintervall", + "include_arp": "Bruk ARP-data (ignorert hvis hotspot-data brukes)", + "include_associated": "Bruk WiFi AP-tilknytningsdata (ignoreres hvis hotspot-data brukes)", + "interfaces": "Velg grensesnitt for \u00e5 skanne", + "scan_interval": "Skanneintervall", + "try_hotspot": "Bruk 'ip hotspot'-data (mest n\u00f8yaktig)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/pl.json b/homeassistant/components/keenetic_ndms2/translations/pl.json new file mode 100644 index 00000000000..13bcbfb91ba --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/pl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Konfiguracja routera Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Czas przed oznaczeniem \"poza domem\"", + "include_arp": "U\u017cyj danych ARP (ignorowane, je\u015bli u\u017cywane s\u0105 dane hotspotu)", + "include_associated": "U\u017cyj danych skojarze\u0144 WiFi AP (ignorowane, je\u015bli u\u017cywane s\u0105 dane hotspotu)", + "interfaces": "Wybierz interfejsy do skanowania", + "scan_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania", + "try_hotspot": "U\u017cyj danych \u201eIP hotspot\u201d (najdok\u0142adniejsze)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/ru.json b/homeassistant/components/keenetic_ndms2/translations/ru.json new file mode 100644 index 00000000000..bfd7f6407e7 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/ru.json @@ -0,0 +1,36 @@ +{ + "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." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430 Keenetic NDMS2" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "include_arp": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 ARP (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", + "include_associated": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u0435\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u0430 WiFi (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 hotspot)", + "interfaces": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u044b \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f", + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f", + "try_hotspot": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 'ip hotspot' (\u043d\u0430\u0438\u0431\u043e\u043b\u0435\u0435 \u0442\u043e\u0447\u043d\u044b\u0435)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/zh-Hant.json b/homeassistant/components/keenetic_ndms2/translations/zh-Hant.json new file mode 100644 index 00000000000..7900f3a8854 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8a2d\u5b9a Keenetic NDMS2 \u8def\u7531\u5668" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "\u5224\u5b9a\u5728\u5bb6\u9593\u9694", + "include_arp": "\u4f7f\u7528 ARP \u8cc7\u6599\uff08\u5047\u5982\u5df2\u4f7f\u7528 hotspot \u8cc7\u6599\u5247\u5ffd\u7565\uff09", + "include_associated": "\u4f7f\u7528 WiFi AP \u95dc\u806f\u8cc7\u6599\uff08\u5047\u5982\u5df2\u4f7f\u7528 hotspot \u8cc7\u6599\u5247\u5ffd\u7565\uff09", + "interfaces": "\u9078\u64c7\u6383\u63cf\u4ecb\u9762", + "scan_interval": "\u6383\u63cf\u9593\u8ddd", + "try_hotspot": "\u4f7f\u7528 'ip hotspot' \u8cc7\u6599\uff08\u6700\u7cbe\u6e96\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keyboard/services.yaml b/homeassistant/components/keyboard/services.yaml index 8e49cdd6a12..d0919d59514 100644 --- a/homeassistant/components/keyboard/services.yaml +++ b/homeassistant/components/keyboard/services.yaml @@ -1,17 +1,29 @@ volume_up: - description: Simulates a key press of the "Volume Up" button on Home Assistant's host machine. + description: + Simulates a key press of the "Volume Up" button on Home Assistant's host + machine volume_down: - description: Simulates a key press of the "Volume Down" button on Home Assistant's host machine. + description: + Simulates a key press of the "Volume Down" button on Home Assistant's host + machine volume_mute: - description: Simulates a key press of the "Volume Mute" button on Home Assistant's host machine. + description: + Simulates a key press of the "Volume Mute" button on Home Assistant's host + machine media_play_pause: - description: Simulates a key press of the "Media Play/Pause" button on Home Assistant's host machine. + description: + Simulates a key press of the "Media Play/Pause" button on Home Assistant's + host machine media_next_track: - description: Simulates a key press of the "Media Next Track" button on Home Assistant's host machine. + description: + Simulates a key press of the "Media Next Track" button on Home Assistant's + host machine media_prev_track: - description: Simulates a key press of the "Media Previous Track" button on Home Assistant's host machine. + description: + Simulates a key press of the "Media Previous Track" button on Home + Assistant's host machine diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 8948fbd0b8f..732008e5780 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + CONF_REPEAT, CONF_SENSORS, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, @@ -28,7 +29,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 65432 -CONF_REPEAT = "repeat" CONF_REMOTES = "remotes" CONF_SENSOR = "sensor" CONF_REMOTE = "remote" diff --git a/homeassistant/components/kira/remote.py b/homeassistant/components/kira/remote.py index c9b51fd7ab7..9c02a3199e4 100644 --- a/homeassistant/components/kira/remote.py +++ b/homeassistant/components/kira/remote.py @@ -6,12 +6,10 @@ from homeassistant.components import remote from homeassistant.const import CONF_DEVICE, CONF_NAME from homeassistant.helpers.entity import Entity -DOMAIN = "kira" +from . import CONF_REMOTE, DOMAIN _LOGGER = logging.getLogger(__name__) -CONF_REMOTE = "remote" - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Kira platform.""" diff --git a/homeassistant/components/kira/sensor.py b/homeassistant/components/kira/sensor.py index 71aeec63232..2d6322918c7 100644 --- a/homeassistant/components/kira/sensor.py +++ b/homeassistant/components/kira/sensor.py @@ -4,14 +4,12 @@ import logging from homeassistant.const import CONF_DEVICE, CONF_NAME, STATE_UNKNOWN from homeassistant.helpers.entity import Entity -DOMAIN = "kira" +from . import CONF_SENSOR, DOMAIN _LOGGER = logging.getLogger(__name__) ICON = "mdi:remote" -CONF_SENSOR = "sensor" - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a Kira sensor.""" diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py new file mode 100644 index 00000000000..b55ab9e1c9c --- /dev/null +++ b/homeassistant/components/kmtronic/__init__.py @@ -0,0 +1,104 @@ +"""The kmtronic integration.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +from pykmtronic.auth import Auth +from pykmtronic.hub import KMTronicHubAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_HOSTNAME, + CONF_PASSWORD, + CONF_USERNAME, + DATA_COORDINATOR, + DATA_HOST, + DATA_HUB, + DOMAIN, + MANUFACTURER, +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +PLATFORMS = ["switch"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the kmtronic component.""" + hass.data[DOMAIN] = {} + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up kmtronic from a config entry.""" + + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth( + session, + f"http://{entry.data[CONF_HOSTNAME]}", + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + hub = KMTronicHubAPI(auth) + + async def async_update_data(): + try: + async with async_timeout.timeout(10): + await hub.async_update_relays() + except aiohttp.client_exceptions.ClientResponseError as err: + raise UpdateFailed(f"Wrong credentials: {err}") from err + except ( + asyncio.TimeoutError, + aiohttp.client_exceptions.ClientConnectorError, + ) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{MANUFACTURER} {hub.name}", + update_method=async_update_data, + update_interval=timedelta(seconds=30), + ) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = { + DATA_HUB: hub, + DATA_HOST: entry.data[DATA_HOST], + DATA_COORDINATOR: coordinator, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py new file mode 100644 index 00000000000..376bb64c34c --- /dev/null +++ b/homeassistant/components/kmtronic/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for kmtronic integration.""" +import logging + +import aiohttp +from pykmtronic.auth import Auth +from pykmtronic.hub import KMTronicHubAPI +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.helpers import aiohttp_client + +from .const import CONF_HOSTNAME, CONF_PASSWORD, CONF_USERNAME +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({CONF_HOSTNAME: str, CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth( + session, + f"http://{data[CONF_HOSTNAME]}", + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + hub = KMTronicHubAPI(auth) + + try: + await hub.async_get_status() + except aiohttp.client_exceptions.ClientResponseError as err: + raise InvalidAuth from err + except aiohttp.client_exceptions.ClientConnectorError as err: + raise CannotConnect from err + + return data + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for kmtronic.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["host"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + 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 InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py new file mode 100644 index 00000000000..58553217799 --- /dev/null +++ b/homeassistant/components/kmtronic/const.py @@ -0,0 +1,16 @@ +"""Constants for the kmtronic integration.""" + +DOMAIN = "kmtronic" + +CONF_HOSTNAME = "host" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" + +DATA_HUB = "hub" +DATA_HOST = "host" +DATA_COORDINATOR = "coordinator" + +MANUFACTURER = "KMtronic" +ATTR_MANUFACTURER = "manufacturer" +ATTR_IDENTIFIERS = "identifiers" +ATTR_NAME = "name" diff --git a/homeassistant/components/kmtronic/manifest.json b/homeassistant/components/kmtronic/manifest.json new file mode 100644 index 00000000000..27e9f953eb7 --- /dev/null +++ b/homeassistant/components/kmtronic/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kmtronic", + "name": "KMtronic", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/kmtronic", + "requirements": ["pykmtronic==0.0.3"], + "codeowners": ["@dgomes"] +} diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json new file mode 100644 index 00000000000..7becb830d91 --- /dev/null +++ b/homeassistant/components/kmtronic/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py new file mode 100644 index 00000000000..5970ec20cb8 --- /dev/null +++ b/homeassistant/components/kmtronic/switch.py @@ -0,0 +1,67 @@ +"""KMtronic Switch integration.""" + +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DATA_COORDINATOR, DATA_HOST, DATA_HUB, DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Config entry example.""" + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + hub = hass.data[DOMAIN][entry.entry_id][DATA_HUB] + host = hass.data[DOMAIN][entry.entry_id][DATA_HOST] + await hub.async_get_relays() + + async_add_entities( + [ + KMtronicSwitch(coordinator, host, relay, entry.unique_id) + for relay in hub.relays + ] + ) + + +class KMtronicSwitch(CoordinatorEntity, SwitchEntity): + """KMtronic Switch Entity.""" + + def __init__(self, coordinator, host, relay, config_entry_id): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self._host = host + self._relay = relay + self._config_entry_id = config_entry_id + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self.coordinator.last_update_success + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"Relay{self._relay.id}" + + @property + def unique_id(self) -> str: + """Return the unique ID of the entity.""" + return f"{self._config_entry_id}_relay{self._relay.id}" + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return True + + @property + def is_on(self): + """Return entity state.""" + return self._relay.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + await self._relay.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + await self._relay.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/components/kmtronic/translations/ca.json b/homeassistant/components/kmtronic/translations/ca.json new file mode 100644 index 00000000000..df8218bab3e --- /dev/null +++ b/homeassistant/components/kmtronic/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/cs.json b/homeassistant/components/kmtronic/translations/cs.json new file mode 100644 index 00000000000..0f02cd974c2 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/de.json b/homeassistant/components/kmtronic/translations/de.json new file mode 100644 index 00000000000..625c7372347 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/en.json b/homeassistant/components/kmtronic/translations/en.json new file mode 100644 index 00000000000..f15fe84c3ed --- /dev/null +++ b/homeassistant/components/kmtronic/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/et.json b/homeassistant/components/kmtronic/translations/et.json new file mode 100644 index 00000000000..0c1715b4932 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/fr.json b/homeassistant/components/kmtronic/translations/fr.json new file mode 100644 index 00000000000..45620fe7795 --- /dev/null +++ b/homeassistant/components/kmtronic/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", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/it.json b/homeassistant/components/kmtronic/translations/it.json new file mode 100644 index 00000000000..e9356485e08 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/nl.json b/homeassistant/components/kmtronic/translations/nl.json new file mode 100644 index 00000000000..8ad15260b0d --- /dev/null +++ b/homeassistant/components/kmtronic/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/no.json b/homeassistant/components/kmtronic/translations/no.json new file mode 100644 index 00000000000..249711bb912 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/pl.json b/homeassistant/components/kmtronic/translations/pl.json new file mode 100644 index 00000000000..25dab56796c --- /dev/null +++ b/homeassistant/components/kmtronic/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/ru.json b/homeassistant/components/kmtronic/translations/ru.json new file mode 100644 index 00000000000..9e0db9fcf94 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/ru.json @@ -0,0 +1,21 @@ +{ + "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.", + "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": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/zh-Hant.json b/homeassistant/components/kmtronic/translations/zh-Hant.json new file mode 100644 index 00000000000..cad7d736a9d --- /dev/null +++ b/homeassistant/components/kmtronic/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 1492e5df7b7..b8feb010e29 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -5,7 +5,6 @@ import logging import voluptuous as vol from xknx import XKNX from xknx.core.telegram_queue import TelegramQueue -from xknx.devices import DateTime, ExposeSensor from xknx.dpt import DPTArray, DPTBase, DPTBinary from xknx.exceptions import XKNXException from xknx.io import ( @@ -15,29 +14,25 @@ from xknx.io import ( ConnectionType, ) from xknx.telegram import AddressFilter, GroupAddress, Telegram -from xknx.telegram.apci import GroupValueResponse, GroupValueWrite +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite from homeassistant.const import ( - CONF_ENTITY_ID, + CONF_ADDRESS, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, - STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.exceptions import 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.event import async_track_state_change_event from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ServiceCallType from .const import DOMAIN, SupportedPlatforms +from .expose import create_knx_exposure from .factory import create_knx_device from .schema import ( BinarySensorSchema, @@ -45,12 +40,15 @@ from .schema import ( ConnectionSchema, CoverSchema, ExposeSchema, + FanSchema, LightSchema, NotifySchema, SceneSchema, SensorSchema, SwitchSchema, WeatherSchema, + ga_validator, + ia_validator, ) _LOGGER = logging.getLogger(__name__) @@ -69,11 +67,12 @@ CONF_KNX_RATE_LIMIT = "rate_limit" CONF_KNX_EXPOSE = "expose" SERVICE_KNX_SEND = "send" -SERVICE_KNX_ATTR_ADDRESS = "address" SERVICE_KNX_ATTR_PAYLOAD = "payload" SERVICE_KNX_ATTR_TYPE = "type" SERVICE_KNX_ATTR_REMOVE = "remove" SERVICE_KNX_EVENT_REGISTER = "event_register" +SERVICE_KNX_EXPOSURE_REGISTER = "exposure_register" +SERVICE_KNX_READ = "read" CONFIG_SCHEMA = vol.Schema( { @@ -95,7 +94,7 @@ CONFIG_SCHEMA = vol.Schema( ), vol.Optional( CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS - ): cv.string, + ): ia_validator, vol.Optional( CONF_KNX_MCAST_GRP, default=DEFAULT_MCAST_GRP ): cv.string, @@ -109,33 +108,36 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_KNX_EXPOSE): vol.All( cv.ensure_list, [ExposeSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.cover.value): vol.All( + vol.Optional(SupportedPlatforms.COVER.value): vol.All( cv.ensure_list, [CoverSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.binary_sensor.value): vol.All( + vol.Optional(SupportedPlatforms.BINARY_SENSOR.value): vol.All( cv.ensure_list, [BinarySensorSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.light.value): vol.All( + vol.Optional(SupportedPlatforms.LIGHT.value): vol.All( cv.ensure_list, [LightSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.climate.value): vol.All( + vol.Optional(SupportedPlatforms.CLIMATE.value): vol.All( cv.ensure_list, [ClimateSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.notify.value): vol.All( + vol.Optional(SupportedPlatforms.NOTIFY.value): vol.All( cv.ensure_list, [NotifySchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.switch.value): vol.All( + vol.Optional(SupportedPlatforms.SWITCH.value): vol.All( cv.ensure_list, [SwitchSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.sensor.value): vol.All( + vol.Optional(SupportedPlatforms.SENSOR.value): vol.All( cv.ensure_list, [SensorSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.scene.value): vol.All( + vol.Optional(SupportedPlatforms.SCENE.value): vol.All( cv.ensure_list, [SceneSchema.SCHEMA] ), - vol.Optional(SupportedPlatforms.weather.value): vol.All( + vol.Optional(SupportedPlatforms.WEATHER.value): vol.All( cv.ensure_list, [WeatherSchema.SCHEMA] ), + vol.Optional(SupportedPlatforms.FAN.value): vol.All( + cv.ensure_list, [FanSchema.SCHEMA] + ), } ), ) @@ -146,7 +148,7 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_KNX_SEND_SCHEMA = vol.Any( vol.Schema( { - vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, vol.Required(SERVICE_KNX_ATTR_TYPE): vol.Any(int, float, str), } @@ -154,7 +156,7 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any( vol.Schema( # without type given payload is treated as raw bytes { - vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( cv.positive_int, [cv.positive_int] ), @@ -162,30 +164,61 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any( ), ) +SERVICE_KNX_READ_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ) + } +) + SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( { - vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, } ) +SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( + ExposeSchema.SCHEMA.extend( + { + vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, + } + ), + vol.Schema( + # for removing only `address` is required + { + vol.Required(CONF_ADDRESS): ga_validator, + vol.Required(SERVICE_KNX_ATTR_REMOVE): vol.All(cv.boolean, True), + }, + extra=vol.ALLOW_EXTRA, + ), +) + async def async_setup(hass, config): """Set up the KNX component.""" try: - hass.data[DOMAIN] = KNXModule(hass, config) - hass.data[DOMAIN].async_create_exposures() - await hass.data[DOMAIN].start() + 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" ) + if CONF_KNX_EXPOSE in config[DOMAIN]: + for expose_config in config[DOMAIN][CONF_KNX_EXPOSE]: + knx_module.exposures.append( + create_knx_exposure(hass, knx_module.xknx, expose_config) + ) + for platform in SupportedPlatforms: if platform.value in config[DOMAIN]: for device_config in config[DOMAIN][platform.value]: - create_knx_device(platform, hass.data[DOMAIN].xknx, device_config) + create_knx_device(platform, knx_module.xknx, device_config) # We need to wait until all entities are loaded into the device list since they could also be created from other platforms for platform in SupportedPlatforms: @@ -193,7 +226,7 @@ async def async_setup(hass, config): discovery.async_load_platform(hass, platform.value, DOMAIN, {}, config) ) - if not hass.data[DOMAIN].xknx.devices: + if not knx_module.xknx.devices: _LOGGER.warning( "No KNX devices are configured. Please read " "https://www.home-assistant.io/blog/2020/09/17/release-115/#breaking-changes" @@ -202,18 +235,33 @@ async def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_KNX_SEND, - hass.data[DOMAIN].service_send_to_knx_bus, + knx_module.service_send_to_knx_bus, schema=SERVICE_KNX_SEND_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_KNX_READ, + knx_module.service_read_to_knx_bus, + schema=SERVICE_KNX_READ_SCHEMA, + ) + async_register_admin_service( hass, DOMAIN, SERVICE_KNX_EVENT_REGISTER, - hass.data[DOMAIN].service_event_register_modify, + knx_module.service_event_register_modify, schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_KNX_EXPOSURE_REGISTER, + knx_module.service_exposure_register_modify, + schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, + ) + async def reload_service_handler(service_call: ServiceCallType) -> None: """Remove all KNX components and load new ones from config.""" @@ -224,7 +272,7 @@ async def async_setup(hass, config): if not config or DOMAIN not in config: return - await hass.data[DOMAIN].xknx.stop() + await knx_module.xknx.stop() await asyncio.gather( *[platform.async_reset() for platform in async_get_platforms(hass, DOMAIN)] @@ -248,6 +296,7 @@ class KNXModule: self.config = config self.connected = False self.exposures = [] + self.service_exposures = {} self.init_xknx() self._knx_event_callback: TelegramQueue.Callback = self.register_callback() @@ -294,9 +343,12 @@ class KNXModule: def connection_config_routing(self): """Return the connection_config if routing is configured.""" - local_ip = self.config[DOMAIN][CONF_KNX_ROUTING].get( - ConnectionSchema.CONF_KNX_LOCAL_IP - ) + 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 + ) return ConnectionConfig( connection_type=ConnectionType.ROUTING, local_ip=local_ip ) @@ -316,34 +368,6 @@ class KNXModule: auto_reconnect=True, ) - @callback - def async_create_exposures(self): - """Create exposures.""" - if CONF_KNX_EXPOSE not in self.config[DOMAIN]: - return - for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]: - expose_type = to_expose.get(ExposeSchema.CONF_KNX_EXPOSE_TYPE) - entity_id = to_expose.get(CONF_ENTITY_ID) - attribute = to_expose.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE) - default = to_expose.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) - address = to_expose.get(ExposeSchema.CONF_KNX_EXPOSE_ADDRESS) - if expose_type.lower() in ["time", "date", "datetime"]: - exposure = KNXExposeTime(self.xknx, expose_type, address) - exposure.async_register() - self.exposures.append(exposure) - else: - exposure = KNXExposeSensor( - self.hass, - self.xknx, - expose_type, - entity_id, - attribute, - default, - address, - ) - exposure.async_register() - self.exposures.append(exposure) - async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" data = None @@ -372,11 +396,12 @@ class KNXModule: self.telegram_received_cb, address_filters=address_filters, group_addresses=[], + match_for_outgoing=True, ) async def service_event_register_modify(self, call): """Service for adding or removing a GroupAddress to the knx_event filter.""" - group_address = GroupAddress(call.data.get(SERVICE_KNX_ATTR_ADDRESS)) + group_address = GroupAddress(call.data[CONF_ADDRESS]) if call.data.get(SERVICE_KNX_ATTR_REMOVE): try: self._knx_event_callback.group_addresses.remove(group_address) @@ -392,10 +417,41 @@ class KNXModule: group_address, ) + async def service_exposure_register_modify(self, call): + """Service for adding or removing an exposure to KNX bus.""" + group_address = call.data.get(CONF_ADDRESS) + + if call.data.get(SERVICE_KNX_ATTR_REMOVE): + try: + removed_exposure = self.service_exposures.pop(group_address) + except KeyError as err: + raise HomeAssistantError( + f"Could not find exposure for '{group_address}' to remove." + ) from err + else: + removed_exposure.shutdown() + return + + if group_address in self.service_exposures: + replaced_exposure = self.service_exposures.pop(group_address) + _LOGGER.warning( + "Service exposure_register replacing already registered exposure for '%s' - %s", + group_address, + replaced_exposure.device.name, + ) + replaced_exposure.shutdown() + exposure = create_knx_exposure(self.hass, self.xknx, call.data) + self.service_exposures[group_address] = exposure + _LOGGER.debug( + "Service exposure_register registered exposure for '%s' - %s", + group_address, + exposure.device.name, + ) + async def service_send_to_knx_bus(self, call): """Service for sending an arbitrary KNX message to the KNX bus.""" attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) - attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) + attr_address = call.data.get(CONF_ADDRESS) attr_type = call.data.get(SERVICE_KNX_ATTR_TYPE) def calculate_payload(attr_payload): @@ -415,92 +471,11 @@ class KNXModule: ) await self.xknx.telegrams.put(telegram) - -class KNXExposeTime: - """Object to Expose Time/Date object to KNX bus.""" - - def __init__(self, xknx: XKNX, expose_type: str, address: str): - """Initialize of Expose class.""" - self.xknx = xknx - self.expose_type = expose_type - self.address = address - self.device = None - - @callback - def async_register(self): - """Register listener.""" - self.device = DateTime( - self.xknx, - name=self.expose_type.capitalize(), - broadcast_type=self.expose_type.upper(), - localtime=True, - group_address=self.address, - ) - - -class KNXExposeSensor: - """Object to Expose Home Assistant entity to KNX bus.""" - - def __init__(self, hass, xknx, expose_type, entity_id, attribute, default, address): - """Initialize of Expose class.""" - self.hass = hass - self.xknx = xknx - self.type = expose_type - self.entity_id = entity_id - self.expose_attribute = attribute - self.expose_default = default - self.address = address - self.device = None - - @callback - def async_register(self): - """Register listener.""" - if self.expose_attribute is not None: - _name = self.entity_id + "__" + self.expose_attribute - else: - _name = self.entity_id - self.device = ExposeSensor( - self.xknx, - name=_name, - group_address=self.address, - value_type=self.type, - ) - async_track_state_change_event( - self.hass, [self.entity_id], self._async_entity_changed - ) - - async def _async_entity_changed(self, event): - """Handle entity change.""" - new_state = event.data.get("new_state") - if new_state is None: - return - if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - return - - if self.expose_attribute is not None: - new_attribute = new_state.attributes.get(self.expose_attribute) - old_state = event.data.get("old_state") - - if old_state is not None: - old_attribute = old_state.attributes.get(self.expose_attribute) - if old_attribute == new_attribute: - # don't send same value sequentially - return - await self._async_set_knx_value(new_attribute) - else: - await self._async_set_knx_value(new_state.state) - - async def _async_set_knx_value(self, value): - """Set new value on xknx ExposeSensor.""" - if value is None: - if self.expose_default is None: - return - value = self.expose_default - - if self.type == "binary": - if value == STATE_ON: - value = True - elif value == STATE_OFF: - value = False - - await self.device.set(value) + async def service_read_to_knx_bus(self, call): + """Service for sending a GroupValueRead telegram to the KNX bus.""" + for address in call.data.get(CONF_ADDRESS): + telegram = Telegram( + destination_address=GroupAddress(address), + payload=GroupValueRead(), + ) + await self.xknx.telegrams.put(telegram) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 35feb09dc1d..f7ec3e80fa1 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -40,7 +40,9 @@ class KNXBinarySensor(KnxEntity, BinarySensorEntity): @property def device_state_attributes(self) -> Optional[Dict[str, Any]]: """Return device specific state attributes.""" - return {ATTR_COUNTER: self._device.counter} + if self._device.counter is not None: + return {ATTR_COUNTER: self._device.counter} + return None @property def force_update(self) -> bool: diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index e434aed395d..83ffc2557c2 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -26,22 +26,23 @@ CONF_RESET_AFTER = "reset_after" class ColorTempModes(Enum): """Color temperature modes for config validation.""" - absolute = "DPT-7.600" - relative = "DPT-5.001" + ABSOLUTE = "DPT-7.600" + RELATIVE = "DPT-5.001" class SupportedPlatforms(Enum): """Supported platforms.""" - cover = "cover" - light = "light" - binary_sensor = "binary_sensor" - climate = "climate" - switch = "switch" - notify = "notify" - scene = "scene" - sensor = "sensor" - weather = "weather" + BINARY_SENSOR = "binary_sensor" + CLIMATE = "climate" + COVER = "cover" + FAN = "fan" + LIGHT = "light" + NOTIFY = "notify" + SCENE = "scene" + SENSOR = "sensor" + SWITCH = "switch" + WEATHER = "weather" # Map KNX controller modes to HA modes. This list might not be complete. diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 33da600976e..4c08612926b 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -7,10 +7,13 @@ from homeassistant.components.cover import ( DEVICE_CLASS_BLIND, DEVICE_CLASSES, SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, SUPPORT_OPEN, + SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, + SUPPORT_STOP_TILT, CoverEntity, ) from homeassistant.core import callback @@ -61,7 +64,12 @@ class KNXCover(KnxEntity, CoverEntity): if self._device.supports_stop: supported_features |= SUPPORT_STOP if self._device.supports_angle: - supported_features |= SUPPORT_SET_TILT_POSITION + supported_features |= ( + SUPPORT_SET_TILT_POSITION + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + ) return supported_features @property @@ -127,6 +135,19 @@ class KNXCover(KnxEntity, CoverEntity): knx_tilt_position = 100 - kwargs[ATTR_TILT_POSITION] await self._device.set_angle(knx_tilt_position) + async def async_open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + await self._device.set_short_up() + + async def async_close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + await self._device.set_short_down() + + async def async_stop_cover_tilt(self, **kwargs): + """Stop the cover tilt.""" + await self._device.stop() + self.stop_auto_updater() + def start_auto_updater(self): """Start the autoupdater to update Home Assistant while cover is moving.""" if self._unsubscribe_auto_updater is None: diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py new file mode 100644 index 00000000000..5abc58f82cc --- /dev/null +++ b/homeassistant/components/knx/expose.py @@ -0,0 +1,154 @@ +"""Exposures to KNX bus.""" +from typing import Union + +from xknx import XKNX +from xknx.devices import DateTime, ExposeSensor + +from homeassistant.const import ( + CONF_ADDRESS, + CONF_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from .schema import ExposeSchema + + +@callback +def create_knx_exposure( + hass: HomeAssistant, xknx: XKNX, config: ConfigType +) -> Union["KNXExposeSensor", "KNXExposeTime"]: + """Create exposures from config.""" + address = config[CONF_ADDRESS] + attribute = config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE) + entity_id = config.get(CONF_ENTITY_ID) + expose_type = config.get(ExposeSchema.CONF_KNX_EXPOSE_TYPE) + default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) + + exposure: Union["KNXExposeSensor", "KNXExposeTime"] + if expose_type.lower() in ["time", "date", "datetime"]: + exposure = KNXExposeTime(xknx, expose_type, address) + else: + exposure = KNXExposeSensor( + hass, + xknx, + expose_type, + entity_id, + attribute, + default, + address, + ) + exposure.async_register() + return exposure + + +class KNXExposeSensor: + """Object to Expose Home Assistant entity to KNX bus.""" + + def __init__(self, hass, xknx, expose_type, entity_id, attribute, default, address): + """Initialize of Expose class.""" + self.hass = hass + self.xknx = xknx + self.type = expose_type + self.entity_id = entity_id + self.expose_attribute = attribute + self.expose_default = default + self.address = address + self.device = None + self._remove_listener = None + + @callback + def async_register(self): + """Register listener.""" + if self.expose_attribute is not None: + _name = self.entity_id + "__" + self.expose_attribute + else: + _name = self.entity_id + self.device = ExposeSensor( + self.xknx, + name=_name, + group_address=self.address, + value_type=self.type, + ) + self._remove_listener = async_track_state_change_event( + self.hass, [self.entity_id], self._async_entity_changed + ) + + @callback + def shutdown(self) -> None: + """Prepare for deletion.""" + if self._remove_listener is not None: + self._remove_listener() + self._remove_listener = None + if self.device is not None: + self.device.shutdown() + + async def _async_entity_changed(self, event): + """Handle entity change.""" + new_state = event.data.get("new_state") + if new_state is None: + return + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + return + + if self.expose_attribute is None: + await self._async_set_knx_value(new_state.state) + return + + new_attribute = new_state.attributes.get(self.expose_attribute) + old_state = event.data.get("old_state") + + if old_state is not None: + old_attribute = old_state.attributes.get(self.expose_attribute) + if old_attribute == new_attribute: + # don't send same value sequentially + return + await self._async_set_knx_value(new_attribute) + + async def _async_set_knx_value(self, value): + """Set new value on xknx ExposeSensor.""" + if value is None: + if self.expose_default is None: + return + value = self.expose_default + + if self.type == "binary": + if value == STATE_ON: + value = True + elif value == STATE_OFF: + value = False + + await self.device.set(value) + + +class KNXExposeTime: + """Object to Expose Time/Date object to KNX bus.""" + + def __init__(self, xknx: XKNX, expose_type: str, address: str): + """Initialize of Expose class.""" + self.xknx = xknx + self.expose_type = expose_type + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + self.device = DateTime( + self.xknx, + name=self.expose_type.capitalize(), + broadcast_type=self.expose_type.upper(), + localtime=True, + group_address=self.address, + ) + + @callback + def shutdown(self): + """Prepare for deletion.""" + if self.device is not None: + self.device.shutdown() diff --git a/homeassistant/components/knx/factory.py b/homeassistant/components/knx/factory.py index c1e73733b22..51a94bc06e3 100644 --- a/homeassistant/components/knx/factory.py +++ b/homeassistant/components/knx/factory.py @@ -8,6 +8,7 @@ from xknx.devices import ( ClimateMode as XknxClimateMode, Cover as XknxCover, Device as XknxDevice, + Fan as XknxFan, Light as XknxLight, Notification as XknxNotification, Scene as XknxScene, @@ -24,6 +25,7 @@ from .schema import ( BinarySensorSchema, ClimateSchema, CoverSchema, + FanSchema, LightSchema, SceneSchema, SensorSchema, @@ -38,33 +40,36 @@ def create_knx_device( config: ConfigType, ) -> XknxDevice: """Return the requested XKNX device.""" - if platform is SupportedPlatforms.light: + if platform is SupportedPlatforms.LIGHT: return _create_light(knx_module, config) - if platform is SupportedPlatforms.cover: + if platform is SupportedPlatforms.COVER: return _create_cover(knx_module, config) - if platform is SupportedPlatforms.climate: + if platform is SupportedPlatforms.CLIMATE: return _create_climate(knx_module, config) - if platform is SupportedPlatforms.switch: + if platform is SupportedPlatforms.SWITCH: return _create_switch(knx_module, config) - if platform is SupportedPlatforms.sensor: + if platform is SupportedPlatforms.SENSOR: return _create_sensor(knx_module, config) - if platform is SupportedPlatforms.notify: + if platform is SupportedPlatforms.NOTIFY: return _create_notify(knx_module, config) - if platform is SupportedPlatforms.scene: + if platform is SupportedPlatforms.SCENE: return _create_scene(knx_module, config) - if platform is SupportedPlatforms.binary_sensor: + if platform is SupportedPlatforms.BINARY_SENSOR: return _create_binary_sensor(knx_module, config) - if platform is SupportedPlatforms.weather: + if platform is SupportedPlatforms.WEATHER: return _create_weather(knx_module, config) + if platform is SupportedPlatforms.FAN: + return _create_fan(knx_module, config) + def _create_cover(knx_module: XKNX, config: ConfigType) -> XknxCover: """Return a KNX Cover device to be used within XKNX.""" @@ -116,12 +121,12 @@ def _create_light(knx_module: XKNX, config: ConfigType) -> XknxLight: group_address_tunable_white_state = None group_address_color_temp = None group_address_color_temp_state = None - if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.absolute: + if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.ABSOLUTE: group_address_color_temp = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) group_address_color_temp_state = config.get( LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS ) - elif config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.relative: + elif config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.RELATIVE: group_address_tunable_white = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) group_address_tunable_white_state = config.get( LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS @@ -259,6 +264,9 @@ def _create_climate(knx_module: XKNX, config: ConfigType) -> XknxClimate: max_temp=config.get(ClimateSchema.CONF_MAX_TEMP), mode=climate_mode, on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT], + create_temperature_sensors=config.get( + ClimateSchema.CONF_CREATE_TEMPERATURE_SENSORS + ), ) @@ -327,7 +335,7 @@ def _create_weather(knx_module: XKNX, config: ConfigType) -> XknxWeather: knx_module, name=config[CONF_NAME], sync_state=config[WeatherSchema.CONF_SYNC_STATE], - expose_sensors=config[WeatherSchema.CONF_KNX_EXPOSE_SENSORS], + create_sensors=config[WeatherSchema.CONF_KNX_CREATE_SENSORS], group_address_temperature=config[WeatherSchema.CONF_KNX_TEMPERATURE_ADDRESS], group_address_brightness_south=config.get( WeatherSchema.CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS @@ -342,6 +350,9 @@ def _create_weather(knx_module: XKNX, config: ConfigType) -> XknxWeather: WeatherSchema.CONF_KNX_BRIGHTNESS_NORTH_ADDRESS ), group_address_wind_speed=config.get(WeatherSchema.CONF_KNX_WIND_SPEED_ADDRESS), + group_address_wind_bearing=config.get( + WeatherSchema.CONF_KNX_WIND_BEARING_ADDRESS + ), group_address_rain_alarm=config.get(WeatherSchema.CONF_KNX_RAIN_ALARM_ADDRESS), group_address_frost_alarm=config.get( WeatherSchema.CONF_KNX_FROST_ALARM_ADDRESS @@ -353,3 +364,20 @@ def _create_weather(knx_module: XKNX, config: ConfigType) -> XknxWeather: ), group_address_humidity=config.get(WeatherSchema.CONF_KNX_HUMIDITY_ADDRESS), ) + + +def _create_fan(knx_module: XKNX, config: ConfigType) -> XknxFan: + """Return a KNX Fan device to be used within XKNX.""" + + fan = XknxFan( + knx_module, + name=config[CONF_NAME], + group_address_speed=config.get(CONF_ADDRESS), + group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS), + group_address_oscillation=config.get(FanSchema.CONF_OSCILLATION_ADDRESS), + group_address_oscillation_state=config.get( + FanSchema.CONF_OSCILLATION_STATE_ADDRESS + ), + max_step=config.get(FanSchema.CONF_MAX_STEP), + ) + return fan diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py new file mode 100644 index 00000000000..43d1cd7d6f2 --- /dev/null +++ b/homeassistant/components/knx/fan.py @@ -0,0 +1,103 @@ +"""Support for KNX/IP fans.""" +import math +from typing import Any, Optional + +from xknx.devices import Fan as XknxFan +from xknx.devices.fan import FanSpeedMode + +from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DOMAIN +from .knx_entity import KnxEntity + +DEFAULT_PERCENTAGE = 50 + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up fans for KNX platform.""" + entities = [] + for device in hass.data[DOMAIN].xknx.devices: + if isinstance(device, XknxFan): + entities.append(KNXFan(device)) + async_add_entities(entities) + + +class KNXFan(KnxEntity, FanEntity): + """Representation of a KNX fan.""" + + def __init__(self, device: XknxFan): + """Initialize of KNX fan.""" + super().__init__(device) + + if self._device.mode == FanSpeedMode.STEP: + self._step_range = (1, device.max_step) + else: + self._step_range = None + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if self._device.mode == FanSpeedMode.STEP: + step = math.ceil(percentage_to_ranged_value(self._step_range, percentage)) + await self._device.set_speed(step) + else: + await self._device.set_speed(percentage) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = SUPPORT_SET_SPEED + + if self._device.supports_oscillation: + flags |= SUPPORT_OSCILLATE + + return flags + + @property + def percentage(self) -> Optional[int]: + """Return the current speed as a percentage.""" + if self._device.current_speed is None: + return None + + if self._device.mode == FanSpeedMode.STEP: + return ranged_value_to_percentage( + self._step_range, self._device.current_speed + ) + return self._device.current_speed + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._step_range is None: + return super().speed_count + return int_states_in_range(self._step_range) + + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + if percentage is None: + await self.async_set_percentage(DEFAULT_PERCENTAGE) + else: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.async_set_percentage(0) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self._device.set_oscillation(oscillating) + + @property + def oscillating(self): + """Return whether or not the fan is currently oscillating.""" + return self._device.current_oscillation diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 93daee0e348..7ca7657d0ff 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -2,7 +2,7 @@ "domain": "knx", "name": "KNX", "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.16.2"], + "requirements": ["xknx==0.17.1"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "silver" } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 22599014d0f..08a8c62adc4 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -2,6 +2,7 @@ import voluptuous as vol from xknx.devices.climate import SetpointShiftMode from xknx.io import DEFAULT_MCAST_PORT +from xknx.telegram.address import GroupAddress, IndividualAddress from homeassistant.const import ( CONF_ADDRESS, @@ -24,6 +25,33 @@ from .const import ( ColorTempModes, ) +################## +# KNX VALIDATORS +################## + +ga_validator = vol.Any( + cv.matches_regex(GroupAddress.ADDRESS_RE), + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + msg="value does not match pattern for KNX group address '
//', '
/' or '' (eg.'1/2/3', '9/234', '123')", +) + +ia_validator = vol.Any( + cv.matches_regex(IndividualAddress.ADDRESS_RE), + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + msg="value does not match pattern for KNX individual address '..' (eg.'1.1.100')", +) + +sync_state_validator = vol.Any( + vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), + cv.boolean, + cv.matches_regex(r"^(init|expire|every)( \d*)?$"), +) + + +############## +# CONNECTION +############## + class ConnectionSchema: """Voluptuous schema for KNX connection.""" @@ -38,48 +66,12 @@ class ConnectionSchema: } ) - ROUTING_SCHEMA = vol.Schema({vol.Optional(CONF_KNX_LOCAL_IP): cv.string}) + ROUTING_SCHEMA = vol.Maybe(vol.Schema({vol.Optional(CONF_KNX_LOCAL_IP): cv.string})) -class CoverSchema: - """Voluptuous schema for KNX covers.""" - - CONF_MOVE_LONG_ADDRESS = "move_long_address" - CONF_MOVE_SHORT_ADDRESS = "move_short_address" - CONF_STOP_ADDRESS = "stop_address" - CONF_POSITION_ADDRESS = "position_address" - CONF_POSITION_STATE_ADDRESS = "position_state_address" - CONF_ANGLE_ADDRESS = "angle_address" - CONF_ANGLE_STATE_ADDRESS = "angle_state_address" - CONF_TRAVELLING_TIME_DOWN = "travelling_time_down" - CONF_TRAVELLING_TIME_UP = "travelling_time_up" - CONF_INVERT_POSITION = "invert_position" - CONF_INVERT_ANGLE = "invert_angle" - - DEFAULT_TRAVEL_TIME = 25 - DEFAULT_NAME = "KNX Cover" - - SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, - vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, - vol.Optional(CONF_STOP_ADDRESS): cv.string, - vol.Optional(CONF_POSITION_ADDRESS): cv.string, - vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, - vol.Optional(CONF_ANGLE_ADDRESS): cv.string, - vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, - vol.Optional( - CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME - ): cv.positive_int, - vol.Optional( - CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME - ): cv.positive_int, - vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, - vol.Optional(CONF_DEVICE_CLASS): cv.string, - } - ) +############# +# PLATFORMS +############# class BinarySensorSchema: @@ -100,13 +92,9 @@ class BinarySensorSchema: vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): vol.Any( - vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), - cv.boolean, - cv.string, - ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, vol.Optional(CONF_IGNORE_INTERNAL_STATE, default=False): cv.boolean, - vol.Required(CONF_STATE_ADDRESS): cv.string, + vol.Required(CONF_STATE_ADDRESS): ga_validator, vol.Optional(CONF_CONTEXT_TIMEOUT): vol.All( vol.Coerce(float), vol.Range(min=0, max=10) ), @@ -118,95 +106,6 @@ class BinarySensorSchema: ) -class LightSchema: - """Voluptuous schema for KNX lights.""" - - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_BRIGHTNESS_ADDRESS = "brightness_address" - CONF_BRIGHTNESS_STATE_ADDRESS = "brightness_state_address" - CONF_COLOR_ADDRESS = "color_address" - CONF_COLOR_STATE_ADDRESS = "color_state_address" - CONF_COLOR_TEMP_ADDRESS = "color_temperature_address" - CONF_COLOR_TEMP_STATE_ADDRESS = "color_temperature_state_address" - CONF_COLOR_TEMP_MODE = "color_temperature_mode" - CONF_RGBW_ADDRESS = "rgbw_address" - CONF_RGBW_STATE_ADDRESS = "rgbw_state_address" - CONF_MIN_KELVIN = "min_kelvin" - CONF_MAX_KELVIN = "max_kelvin" - - DEFAULT_NAME = "KNX Light" - DEFAULT_COLOR_TEMP_MODE = "absolute" - DEFAULT_MIN_KELVIN = 2700 # 370 mireds - DEFAULT_MAX_KELVIN = 6000 # 166 mireds - - CONF_INDIVIDUAL_COLORS = "individual_colors" - CONF_RED = "red" - CONF_GREEN = "green" - CONF_BLUE = "blue" - CONF_WHITE = "white" - - COLOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ADDRESS): cv.string, - vol.Optional(CONF_STATE_ADDRESS): cv.string, - vol.Required(CONF_BRIGHTNESS_ADDRESS): cv.string, - vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, - } - ) - - SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_ADDRESS): cv.string, - vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string, - vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, - vol.Exclusive(CONF_INDIVIDUAL_COLORS, "color"): { - vol.Inclusive(CONF_RED, "colors"): COLOR_SCHEMA, - vol.Inclusive(CONF_GREEN, "colors"): COLOR_SCHEMA, - vol.Inclusive(CONF_BLUE, "colors"): COLOR_SCHEMA, - vol.Optional(CONF_WHITE): COLOR_SCHEMA, - }, - vol.Exclusive(CONF_COLOR_ADDRESS, "color"): cv.string, - vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string, - vol.Optional(CONF_COLOR_TEMP_ADDRESS): cv.string, - vol.Optional(CONF_COLOR_TEMP_STATE_ADDRESS): cv.string, - vol.Optional( - CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE - ): cv.enum(ColorTempModes), - vol.Exclusive(CONF_RGBW_ADDRESS, "color"): cv.string, - vol.Optional(CONF_RGBW_STATE_ADDRESS): cv.string, - vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } - ), - vol.Any( - # either global "address" or all addresses for individual colors are required - vol.Schema( - { - vol.Required(CONF_INDIVIDUAL_COLORS): { - vol.Required(CONF_RED): {vol.Required(CONF_ADDRESS): object}, - vol.Required(CONF_GREEN): {vol.Required(CONF_ADDRESS): object}, - vol.Required(CONF_BLUE): {vol.Required(CONF_ADDRESS): object}, - }, - }, - extra=vol.ALLOW_EXTRA, - ), - vol.Schema( - { - vol.Required(CONF_ADDRESS): object, - }, - extra=vol.ALLOW_EXTRA, - ), - ), - ) - - class ClimateSchema: """Voluptuous schema for KNX climate devices.""" @@ -240,6 +139,7 @@ class ClimateSchema: CONF_ON_OFF_INVERT = "on_off_invert" CONF_MIN_TEMP = "min_temp" CONF_MAX_TEMP = "max_temp" + CONF_CREATE_TEMPERATURE_SENSORS = "create_temperature_sensors" DEFAULT_NAME = "KNX Climate" DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010" @@ -255,7 +155,7 @@ class ClimateSchema: vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional( CONF_SETPOINT_SHIFT_MODE, default=DEFAULT_SETPOINT_SHIFT_MODE - ): cv.enum(SetpointShiftMode), + ): vol.All(vol.Upper, cv.enum(SetpointShiftMode)), vol.Optional( CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX ): vol.All(int, vol.Range(min=0, max=32)), @@ -265,25 +165,27 @@ class ClimateSchema: vol.Optional( CONF_TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP ): vol.All(float, vol.Range(min=0, max=2)), - vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string, - vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, - vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, - vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, - vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, - vol.Optional(CONF_CONTROLLER_MODE_ADDRESS): cv.string, - vol.Optional(CONF_CONTROLLER_MODE_STATE_ADDRESS): cv.string, - vol.Optional(CONF_HEAT_COOL_ADDRESS): cv.string, - vol.Optional(CONF_HEAT_COOL_STATE_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_STANDBY_ADDRESS): cv.string, - vol.Optional(CONF_ON_OFF_ADDRESS): cv.string, - vol.Optional(CONF_ON_OFF_STATE_ADDRESS): cv.string, + vol.Required(CONF_TEMPERATURE_ADDRESS): ga_validator, + vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): ga_validator, + vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): ga_validator, + vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): ga_validator, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): ga_validator, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_CONTROLLER_MODE_ADDRESS): ga_validator, + vol.Optional(CONF_CONTROLLER_MODE_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_HEAT_COOL_ADDRESS): ga_validator, + vol.Optional(CONF_HEAT_COOL_STATE_ADDRESS): ga_validator, + vol.Optional( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS + ): ga_validator, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): ga_validator, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): ga_validator, + vol.Optional(CONF_OPERATION_MODE_STANDBY_ADDRESS): ga_validator, + vol.Optional(CONF_ON_OFF_ADDRESS): ga_validator, + vol.Optional(CONF_ON_OFF_STATE_ADDRESS): ga_validator, vol.Optional( CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT ): cv.boolean, @@ -295,24 +197,51 @@ class ClimateSchema: ), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), + vol.Optional( + CONF_CREATE_TEMPERATURE_SENSORS, default=False + ): cv.boolean, } ), ) -class SwitchSchema: - """Voluptuous schema for KNX switches.""" +class CoverSchema: + """Voluptuous schema for KNX covers.""" - CONF_INVERT = CONF_INVERT - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS + CONF_MOVE_LONG_ADDRESS = "move_long_address" + CONF_MOVE_SHORT_ADDRESS = "move_short_address" + CONF_STOP_ADDRESS = "stop_address" + CONF_POSITION_ADDRESS = "position_address" + CONF_POSITION_STATE_ADDRESS = "position_state_address" + CONF_ANGLE_ADDRESS = "angle_address" + CONF_ANGLE_STATE_ADDRESS = "angle_state_address" + CONF_TRAVELLING_TIME_DOWN = "travelling_time_down" + CONF_TRAVELLING_TIME_UP = "travelling_time_up" + CONF_INVERT_POSITION = "invert_position" + CONF_INVERT_ANGLE = "invert_angle" + + DEFAULT_TRAVEL_TIME = 25 + DEFAULT_NAME = "KNX Cover" - DEFAULT_NAME = "KNX Switch" SCHEMA = vol.Schema( { - vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_ADDRESS): cv.string, - vol.Optional(CONF_INVERT): cv.boolean, + vol.Optional(CONF_MOVE_LONG_ADDRESS): ga_validator, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): ga_validator, + vol.Optional(CONF_STOP_ADDRESS): ga_validator, + vol.Optional(CONF_POSITION_ADDRESS): ga_validator, + vol.Optional(CONF_POSITION_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_ANGLE_ADDRESS): ga_validator, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): ga_validator, + vol.Optional( + CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME + ): cv.positive_float, + vol.Optional( + CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME + ): cv.positive_float, + vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, + vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, + vol.Optional(CONF_DEVICE_CLASS): cv.string, } ) @@ -323,19 +252,129 @@ class ExposeSchema: CONF_KNX_EXPOSE_TYPE = CONF_TYPE CONF_KNX_EXPOSE_ATTRIBUTE = "attribute" CONF_KNX_EXPOSE_DEFAULT = "default" - CONF_KNX_EXPOSE_ADDRESS = CONF_ADDRESS SCHEMA = vol.Schema( { vol.Required(CONF_KNX_EXPOSE_TYPE): vol.Any(int, float, str), + vol.Required(CONF_ADDRESS): ga_validator, vol.Optional(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_KNX_EXPOSE_ATTRIBUTE): cv.string, vol.Optional(CONF_KNX_EXPOSE_DEFAULT): cv.match_all, - vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string, } ) +class FanSchema: + """Voluptuous schema for KNX fans.""" + + CONF_STATE_ADDRESS = CONF_STATE_ADDRESS + CONF_OSCILLATION_ADDRESS = "oscillation_address" + CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address" + CONF_MAX_STEP = "max_step" + + DEFAULT_NAME = "KNX Fan" + + SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_OSCILLATION_ADDRESS): ga_validator, + vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_MAX_STEP): cv.byte, + } + ) + + +class LightSchema: + """Voluptuous schema for KNX lights.""" + + CONF_STATE_ADDRESS = CONF_STATE_ADDRESS + CONF_BRIGHTNESS_ADDRESS = "brightness_address" + CONF_BRIGHTNESS_STATE_ADDRESS = "brightness_state_address" + CONF_COLOR_ADDRESS = "color_address" + CONF_COLOR_STATE_ADDRESS = "color_state_address" + CONF_COLOR_TEMP_ADDRESS = "color_temperature_address" + CONF_COLOR_TEMP_STATE_ADDRESS = "color_temperature_state_address" + CONF_COLOR_TEMP_MODE = "color_temperature_mode" + CONF_RGBW_ADDRESS = "rgbw_address" + CONF_RGBW_STATE_ADDRESS = "rgbw_state_address" + CONF_MIN_KELVIN = "min_kelvin" + CONF_MAX_KELVIN = "max_kelvin" + + DEFAULT_NAME = "KNX Light" + DEFAULT_COLOR_TEMP_MODE = "absolute" + DEFAULT_MIN_KELVIN = 2700 # 370 mireds + DEFAULT_MAX_KELVIN = 6000 # 166 mireds + + CONF_INDIVIDUAL_COLORS = "individual_colors" + CONF_RED = "red" + CONF_GREEN = "green" + CONF_BLUE = "blue" + CONF_WHITE = "white" + + COLOR_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ADDRESS): ga_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_validator, + vol.Required(CONF_BRIGHTNESS_ADDRESS): ga_validator, + vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): ga_validator, + } + ) + + SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ADDRESS): ga_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_BRIGHTNESS_ADDRESS): ga_validator, + vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): ga_validator, + vol.Exclusive(CONF_INDIVIDUAL_COLORS, "color"): { + vol.Inclusive(CONF_RED, "colors"): COLOR_SCHEMA, + vol.Inclusive(CONF_GREEN, "colors"): COLOR_SCHEMA, + vol.Inclusive(CONF_BLUE, "colors"): COLOR_SCHEMA, + vol.Optional(CONF_WHITE): COLOR_SCHEMA, + }, + vol.Exclusive(CONF_COLOR_ADDRESS, "color"): ga_validator, + vol.Optional(CONF_COLOR_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_COLOR_TEMP_ADDRESS): ga_validator, + vol.Optional(CONF_COLOR_TEMP_STATE_ADDRESS): ga_validator, + vol.Optional( + CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE + ): vol.All(vol.Upper, cv.enum(ColorTempModes)), + vol.Exclusive(CONF_RGBW_ADDRESS, "color"): ga_validator, + vol.Optional(CONF_RGBW_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ), + vol.Any( + # either global "address" or all addresses for individual colors are required + vol.Schema( + { + vol.Required(CONF_INDIVIDUAL_COLORS): { + vol.Required(CONF_RED): {vol.Required(CONF_ADDRESS): object}, + vol.Required(CONF_GREEN): {vol.Required(CONF_ADDRESS): object}, + vol.Required(CONF_BLUE): {vol.Required(CONF_ADDRESS): object}, + }, + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required(CONF_ADDRESS): object, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + ) + + class NotifySchema: """Voluptuous schema for KNX notifications.""" @@ -343,8 +382,23 @@ class NotifySchema: SCHEMA = vol.Schema( { - vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, + } + ) + + +class SceneSchema: + """Voluptuous schema for KNX scenes.""" + + CONF_SCENE_NUMBER = "scene_number" + + DEFAULT_NAME = "KNX SCENE" + SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): ga_validator, + vol.Required(CONF_SCENE_NUMBER): cv.positive_int, } ) @@ -360,29 +414,27 @@ class SensorSchema: SCHEMA = vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): vol.Any( - vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), - cv.boolean, - cv.string, - ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, - vol.Required(CONF_STATE_ADDRESS): cv.string, + vol.Required(CONF_STATE_ADDRESS): ga_validator, vol.Required(CONF_TYPE): vol.Any(int, float, str), } ) -class SceneSchema: - """Voluptuous schema for KNX scenes.""" +class SwitchSchema: + """Voluptuous schema for KNX switches.""" - CONF_SCENE_NUMBER = "scene_number" + CONF_INVERT = CONF_INVERT + CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - DEFAULT_NAME = "KNX SCENE" + DEFAULT_NAME = "KNX Switch" SCHEMA = vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_ADDRESS): cv.string, - vol.Required(CONF_SCENE_NUMBER): cv.positive_int, + vol.Required(CONF_ADDRESS): ga_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_validator, + vol.Optional(CONF_INVERT): cv.boolean, } ) @@ -397,36 +449,34 @@ class WeatherSchema: CONF_KNX_BRIGHTNESS_WEST_ADDRESS = "address_brightness_west" CONF_KNX_BRIGHTNESS_NORTH_ADDRESS = "address_brightness_north" CONF_KNX_WIND_SPEED_ADDRESS = "address_wind_speed" + CONF_KNX_WIND_BEARING_ADDRESS = "address_wind_bearing" CONF_KNX_RAIN_ALARM_ADDRESS = "address_rain_alarm" CONF_KNX_FROST_ALARM_ADDRESS = "address_frost_alarm" CONF_KNX_WIND_ALARM_ADDRESS = "address_wind_alarm" CONF_KNX_DAY_NIGHT_ADDRESS = "address_day_night" CONF_KNX_AIR_PRESSURE_ADDRESS = "address_air_pressure" CONF_KNX_HUMIDITY_ADDRESS = "address_humidity" - CONF_KNX_EXPOSE_SENSORS = "expose_sensors" + CONF_KNX_CREATE_SENSORS = "create_sensors" DEFAULT_NAME = "KNX Weather Station" SCHEMA = vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): vol.Any( - vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), - cv.boolean, - cv.string, - ), - vol.Optional(CONF_KNX_EXPOSE_SENSORS, default=False): cv.boolean, - vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): cv.string, - vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): cv.string, - vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): cv.string, - vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): cv.string, - vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): cv.string, - vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): cv.string, - vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): cv.string, - vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): cv.string, - vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): cv.string, - vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): cv.string, - vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): cv.string, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_KNX_CREATE_SENSORS, default=False): cv.boolean, + vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_validator, + vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_validator, } ) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index dc9ffcb61b7..2409d7a6425 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -30,7 +30,7 @@ class KNXSensor(KnxEntity, Entity): return self._device.resolve_state() @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return self._device.unit_of_measurement() diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index cab8c100b01..3fae7dfce0e 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -1,20 +1,99 @@ send: + name: "Send to KNX bus" description: "Send arbitrary data directly to the KNX bus." fields: address: + name: "Group address" description: "Group address(es) to write to." + required: true example: "1/1/0" + selector: + text: payload: + name: "Payload" description: "Payload to send to the bus. Integers are treated as DPT 1/2/3 payloads. For DPTs > 6 bits send a list. Each value represents 1 octet (0-255). Pad with 0 to DPT byte length." + required: true example: "[0, 4]" + selector: + object: type: + name: "Value type" description: "Optional. If set, the payload will not be sent as raw bytes, but encoded as given DPT. Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)." + required: false example: "temperature" + selector: + text: +read: + name: "Read from KNX bus" + description: "Send GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities." + fields: + address: + name: "Group address" + description: "Group address(es) to send read request to. Lists will read multiple group addresses." + required: true + example: "1/1/0" + selector: + text: event_register: + name: "Register knx_event" description: "Add or remove single group address to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed." fields: address: + name: "Group address" description: "Group address that shall be added or removed." + required: true example: "1/1/0" + selector: + text: remove: - description: "Optional. If `True` the group address will be removed. Defaults to `False`." + name: "Remove event registration" + description: "Optional. If `True` the group address will be removed." + default: false + selector: + boolean: +exposure_register: + name: "Expose to KNX bus" + description: "Add or remove exposures to KNX bus. Only exposures added with this service can be removed." + fields: + address: + name: "Group address" + description: "Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered." + required: true + example: "1/1/0" + selector: + text: + type: + name: "Value type" + description: "Telegrams will be encoded as given DPT. 'binary' and all Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)" + required: true + example: "percentU8" + selector: + text: + entity_id: + name: "Entity" + description: "Entity id whose state or attribute shall be exposed." + required: true + example: "light.kitchen" + selector: + entity: + attribute: + name: "Entity attribute" + description: "Optional. Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”." + example: "brightness" + selector: + text: + default: + name: "Default value" + description: "Optional. Default value to send to the bus if the state or attribute value is None. Eg. a light with state “off” has no brightness attribute so a default value of 0 could be used. If not set (or None) no value would be sent to the bus and a GroupReadRequest to the address would return the last known value." + example: "0" + selector: + object: + remove: + name: "Remove exposure" + description: "Optional. If `True` the exposure will be removed. Only `address` is required for removal." + default: false + selector: + boolean: +reload: + name: "Reload KNX configuration" + description: "Reload the KNX configuration from YAML." diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 097fa661f4a..031af9f5af0 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -53,7 +53,12 @@ class KNXWeather(KnxEntity, WeatherEntity): @property def humidity(self): """Return current humidity.""" - return self._device.humidity if self._device.humidity is not None else None + return self._device.humidity + + @property + def wind_bearing(self): + """Return current wind bearing in degrees.""" + return self._device.wind_bearing @property def wind_speed(self): diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index c48e4564f92..69460a57570 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -119,7 +119,6 @@ class KodiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {CONF_NAME: self._name}}) try: diff --git a/homeassistant/components/kodi/translations/fr.json b/homeassistant/components/kodi/translations/fr.json index fd0d3e38e81..a7c4b3f34a1 100644 --- a/homeassistant/components/kodi/translations/fr.json +++ b/homeassistant/components/kodi/translations/fr.json @@ -4,6 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification erron\u00e9e", + "no_uuid": "L'instance Kodi n'a pas d'identifiant unique. Cela est probablement d\u00fb \u00e0 une ancienne version de Kodi (17.x ou inf\u00e9rieure). Vous pouvez configurer l'int\u00e9gration manuellement ou passer \u00e0 une version plus r\u00e9cente de Kodi.", "unknown": "Erreur inattendue" }, "error": { diff --git a/homeassistant/components/kodi/translations/ko.json b/homeassistant/components/kodi/translations/ko.json index 64b08475b68..233cd068a1e 100644 --- a/homeassistant/components/kodi/translations/ko.json +++ b/homeassistant/components/kodi/translations/ko.json @@ -1,17 +1,23 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, + "flow_title": "Kodi: {name}", "step": { "credentials": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, "description": "Kodi \uc0ac\uc6a9\uc790\uba85\uacfc \uc554\ud638\ub97c \uc785\ub825\ud558\uc2ed\uc2dc\uc624. \uc774\ub7ec\ud55c \ub0b4\uc6a9\uc740 \uc2dc\uc2a4\ud15c/\uc124\uc815/\ub124\ud2b8\uc6cc\ud06c/\uc11c\ube44\uc2a4\uc5d0\uc11c \ucc3e\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "discovery_confirm": { @@ -19,9 +25,17 @@ "title": "Kodi \ubc1c\uacac" }, "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8", + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9" + }, "description": "Kodi \uc5f0\uacb0 \uc815\ubcf4. \uc2dc\uc2a4\ud15c / \uc124\uc815 / \ub124\ud2b8\uc6cc\ud06c / \uc11c\ube44\uc2a4\uc5d0\uc11c \"HTTP\ub97c \ud1b5\ud55c Kodi \uc81c\uc5b4 \ud5c8\uc6a9\"\uc744 \ud65c\uc131\ud654\ud588\ub294\uc9c0 \ud655\uc778\ud558\uc2ed\uc2dc\uc624." }, "ws_port": { + "data": { + "ws_port": "\ud3ec\ud2b8" + }, "description": "WebSocket \ud3ec\ud2b8 (Kodi\uc5d0\uc11c TCP \ud3ec\ud2b8\ub77c\uace0\ub3c4 \ud568). WebSocket\uc744 \ud1b5\ud574 \uc5f0\uacb0\ud558\ub824\uba74 \uc2dc\uc2a4\ud15c / \uc124\uc815 / \ub124\ud2b8\uc6cc\ud06c / \uc11c\ube44\uc2a4\uc5d0\uc11c \"\ud504\ub85c\uadf8\ub7a8\uc774 Kodi\ub97c \uc81c\uc5b4\ud558\ub3c4\ub85d \ud5c8\uc6a9\"\uc744 \ud65c\uc131\ud654\ud574\uc57c\ud569\ub2c8\ub2e4. WebSocket\uc774 \ud65c\uc131\ud654\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \ud3ec\ud2b8\ub97c \uc81c\uac70\ud558\uace0 \ube44\uc6cc \ub461\ub2c8\ub2e4." } } diff --git a/homeassistant/components/kodi/translations/nl.json b/homeassistant/components/kodi/translations/nl.json index 8eb4a39cfb6..57476791b8f 100644 --- a/homeassistant/components/kodi/translations/nl.json +++ b/homeassistant/components/kodi/translations/nl.json @@ -11,6 +11,7 @@ "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, + "flow_title": "Kodi: {name}", "step": { "credentials": { "data": { @@ -19,11 +20,15 @@ }, "description": "Voer uw Kodi gebruikersnaam en wachtwoord in. Deze zijn te vinden in Systeem / Instellingen / Netwerk / Services." }, + "discovery_confirm": { + "description": "Wil je Kodi (`{name}`) toevoegen aan Home Assistant?", + "title": "Kodi ontdekt" + }, "user": { "data": { "host": "Host", "port": "Poort", - "ssl": "Maak verbinding via SSL" + "ssl": "Gebruik een SSL-certificaat" }, "description": "Kodi-verbindingsinformatie. Zorg ervoor dat u \"Controle van Kodi via HTTP toestaan\" in Systeem / Instellingen / Netwerk / Services inschakelt." }, diff --git a/homeassistant/components/kodi/translations/ru.json b/homeassistant/components/kodi/translations/ru.json index 312008c9b62..50742417f28 100644 --- a/homeassistant/components/kodi/translations/ru.json +++ b/homeassistant/components/kodi/translations/ru.json @@ -3,13 +3,13 @@ "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.", "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "no_uuid": "\u0423 \u044d\u0442\u043e\u0433\u043e \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440\u0430 Kodi \u043d\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0430. \u0421\u043a\u043e\u0440\u0435\u0435 \u0432\u0441\u0435\u0433\u043e, \u044d\u0442\u043e \u0441\u0432\u044f\u0437\u0430\u043d\u043e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u0441\u0442\u0430\u0440\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0435\u0439 Kodi (17.x \u0438\u043b\u0438 \u043d\u0438\u0436\u0435). \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043d\u043e\u0432\u0443\u044e \u0432\u0435\u0440\u0441\u0438\u044e Kodi.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "Kodi: {name}", diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index a6bc7eff5ca..348eaeda3ac 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -18,11 +18,13 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, CONF_DEVICES, + CONF_DISCOVERY, CONF_HOST, CONF_ID, CONF_NAME, CONF_PIN, CONF_PORT, + CONF_REPEAT, CONF_SENSORS, CONF_SWITCHES, CONF_TYPE, @@ -48,12 +50,10 @@ from .const import ( CONF_ACTIVATION, CONF_API_HOST, CONF_BLINK, - CONF_DISCOVERY, CONF_INVERSE, CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, - CONF_REPEAT, DOMAIN, PIN_TO_ZONE, STATE_HIGH, diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 4e8d13c999e..219148e37cf 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -17,10 +17,12 @@ from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODE from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, + CONF_DISCOVERY, CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT, + CONF_REPEAT, CONF_SENSORS, CONF_SWITCHES, CONF_TYPE, @@ -34,13 +36,11 @@ from .const import ( CONF_API_HOST, CONF_BLINK, CONF_DEFAULT_OPTIONS, - CONF_DISCOVERY, CONF_INVERSE, CONF_MODEL, CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, - CONF_REPEAT, DOMAIN, STATE_HIGH, STATE_LOW, @@ -169,8 +169,6 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # class variable to store/share discovered host information discovered_hosts = {} - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - def __init__(self): """Initialize the Konnected flow.""" self.data = {} diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index c1e7d6b6f26..270b2604538 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -9,10 +9,8 @@ CONF_MOMENTARY = "momentary" CONF_PAUSE = "pause" CONF_POLL_INTERVAL = "poll_interval" CONF_PRECISION = "precision" -CONF_REPEAT = "repeat" CONF_INVERSE = "inverse" CONF_BLINK = "blink" -CONF_DISCOVERY = "discovery" CONF_DHT_SENSORS = "dht_sensors" CONF_DS18B20_SENSORS = "ds18b20_sensors" CONF_MODEL = "model" diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 76e75159290..18f2ed64a1d 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -10,11 +10,13 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, CONF_DEVICES, + CONF_DISCOVERY, CONF_HOST, CONF_ID, CONF_NAME, CONF_PIN, CONF_PORT, + CONF_REPEAT, CONF_SENSORS, CONF_SWITCHES, CONF_TYPE, @@ -31,13 +33,11 @@ from .const import ( CONF_BLINK, CONF_DEFAULT_OPTIONS, CONF_DHT_SENSORS, - CONF_DISCOVERY, CONF_DS18B20_SENSORS, CONF_INVERSE, CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, - CONF_REPEAT, DOMAIN, ENDPOINT_ROOT, STATE_LOW, diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index 4fd62dee8e7..e53838ad0d7 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -99,7 +99,7 @@ } }, "error": { - "bad_host": "Invalid Override API host url" + "bad_host": "Invalid Override API host URL" }, "abort": { "not_konn_panel": "Not a recognized Konnected.io device" diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 1d26f7875c7..b599fe55242 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -5,6 +5,7 @@ from homeassistant.const import ( ATTR_STATE, CONF_DEVICES, CONF_NAME, + CONF_REPEAT, CONF_SWITCHES, CONF_ZONE, ) @@ -14,7 +15,6 @@ from .const import ( CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, - CONF_REPEAT, DOMAIN as KONNECTED_DOMAIN, STATE_HIGH, STATE_LOW, diff --git a/homeassistant/components/konnected/translations/en.json b/homeassistant/components/konnected/translations/en.json index 920e1453e49..32cf120e8af 100644 --- a/homeassistant/components/konnected/translations/en.json +++ b/homeassistant/components/konnected/translations/en.json @@ -32,7 +32,7 @@ "not_konn_panel": "Not a recognized Konnected.io device" }, "error": { - "bad_host": "Invalid Override API host url" + "bad_host": "Invalid Override API host URL" }, "step": { "options_binary": { diff --git a/homeassistant/components/konnected/translations/it.json b/homeassistant/components/konnected/translations/it.json index b618ee04b48..da88fb0ac4d 100644 --- a/homeassistant/components/konnected/translations/it.json +++ b/homeassistant/components/konnected/translations/it.json @@ -32,7 +32,7 @@ "not_konn_panel": "Non \u00e8 un dispositivo Konnected.io riconosciuto" }, "error": { - "bad_host": "URL dell'host API di sostituzione non valido" + "bad_host": "URL host API di sostituzione non valido" }, "step": { "options_binary": { diff --git a/homeassistant/components/konnected/translations/ko.json b/homeassistant/components/konnected/translations/ko.json index d8d2b70d909..fe5b9a0347a 100644 --- a/homeassistant/components/konnected/translations/ko.json +++ b/homeassistant/components/konnected/translations/ko.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "not_konn_panel": "\uc778\uc2dd\ub41c Konnected.io \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "{host}:{port} \uc758 Konnected \ud328\ub110\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "confirm": { @@ -38,7 +38,7 @@ "options_binary": { "data": { "inverse": "\uc5f4\ub9bc / \ub2eb\ud798 \uc0c1\ud0dc \ubc18\uc804", - "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "name": "\uc774\ub984 (\uc120\ud0dd\uc0ac\ud56d)", "type": "\uc774\uc9c4 \uc13c\uc11c \uc720\ud615" }, "description": "{zone} \uc635\uc158", @@ -46,7 +46,7 @@ }, "options_digital": { "data": { - "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "name": "\uc774\ub984 (\uc120\ud0dd\uc0ac\ud56d)", "poll_interval": "\ud3f4\ub9c1 \uac04\uaca9 (\ubd84) (\uc120\ud0dd \uc0ac\ud56d)", "type": "\uc13c\uc11c \uc720\ud615" }, @@ -96,7 +96,7 @@ "activation": "\uc2a4\uc704\uce58\uac00 \ucf1c\uc9c8 \ub54c \ucd9c\ub825", "momentary": "\ud384\uc2a4 \uc9c0\uc18d\uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", "more_states": "\uc774 \uad6c\uc5ed\uc5d0 \ub300\ud55c \ucd94\uac00 \uc0c1\ud0dc \uad6c\uc131", - "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "name": "\uc774\ub984 (\uc120\ud0dd\uc0ac\ud56d)", "pause": "\ud384\uc2a4 \uac04 \uc77c\uc2dc\uc815\uc9c0 \uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", "repeat": "\ubc18\ubcf5 \uc2dc\uac04 (-1 = \ubb34\ud55c) (\uc120\ud0dd \uc0ac\ud56d)" }, diff --git a/homeassistant/components/konnected/translations/no.json b/homeassistant/components/konnected/translations/no.json index 1a11c7c76d3..47be4c20bf0 100644 --- a/homeassistant/components/konnected/translations/no.json +++ b/homeassistant/components/konnected/translations/no.json @@ -32,7 +32,7 @@ "not_konn_panel": "Ikke en anerkjent Konnected.io-enhet" }, "error": { - "bad_host": "Ugyldig overstyr API-vertsadresse" + "bad_host": "Url-adresse for ugyldig overstyring av API-vert" }, "step": { "options_binary": { diff --git a/homeassistant/components/konnected/translations/ru.json b/homeassistant/components/konnected/translations/ru.json index 931f0802dc0..4357c924572 100644 --- a/homeassistant/components/konnected/translations/ru.json +++ b/homeassistant/components/konnected/translations/ru.json @@ -32,7 +32,7 @@ "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." }, "error": { - "bad_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0445\u043e\u0441\u0442\u0430 API." + "bad_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 Override API." }, "step": { "options_binary": { diff --git a/homeassistant/components/kulersky/translations/fr.json b/homeassistant/components/kulersky/translations/fr.json index 4c984a55690..42f356ac365 100644 --- a/homeassistant/components/kulersky/translations/fr.json +++ b/homeassistant/components/kulersky/translations/fr.json @@ -1,7 +1,13 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil n'a \u00e9t\u00e9 d\u00e9tect\u00e9 sur le r\u00e9seau" + "no_devices_found": "Aucun appareil n'a \u00e9t\u00e9 d\u00e9tect\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Seulement une seule configuration est possible " + }, + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration ?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/kulersky/translations/ko.json b/homeassistant/components/kulersky/translations/ko.json new file mode 100644 index 00000000000..7011a61f757 --- /dev/null +++ b/homeassistant/components/kulersky/translations/ko.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kulersky/translations/nl.json b/homeassistant/components/kulersky/translations/nl.json new file mode 100644 index 00000000000..d11896014fd --- /dev/null +++ b/homeassistant/components/kulersky/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je beginnen met instellen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 415668f5924..56a5ea6e646 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -2,10 +2,10 @@ import pypck from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, CONF_SOURCE from . import LcnEntity -from .const import BINSENSOR_PORTS, CONF_CONNECTIONS, CONF_SOURCE, DATA_LCN, SETPOINTS +from .const import BINSENSOR_PORTS, CONF_CONNECTIONS, DATA_LCN, SETPOINTS from .helpers import get_connection diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index ece3994f651..e3269a51cd6 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -3,7 +3,12 @@ import pypck from homeassistant.components.climate import ClimateEntity, const -from homeassistant.const import ATTR_TEMPERATURE, CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_ADDRESS, + CONF_SOURCE, + CONF_UNIT_OF_MEASUREMENT, +) from . import LcnEntity from .const import ( @@ -12,7 +17,6 @@ from .const import ( CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, - CONF_SOURCE, DATA_LCN, ) from .helpers import get_connection diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 821a7102154..3dcac6fb55f 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -25,7 +25,6 @@ CONF_LOCKABLE = "lockable" CONF_VARIABLE = "variable" CONF_VALUE = "value" CONF_RELVARREF = "value_reference" -CONF_SOURCE = "source" CONF_SETPOINT = "setpoint" CONF_LED = "led" CONF_KEYS = "keys" @@ -40,7 +39,6 @@ CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" CONF_SCENES = "scenes" CONF_REGISTER = "register" -CONF_SCENE = "scene" CONF_OUTPUTS = "outputs" CONF_REVERSE_TIME = "reverse_time" diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 5242ed1cc59..8a76056ff46 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -166,7 +166,6 @@ class LcnRelayLight(LcnEntity, LightEntity): async def async_turn_on(self, **kwargs): """Turn the entity on.""" - states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): @@ -176,7 +175,6 @@ class LcnRelayLight(LcnEntity, LightEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" - states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index ed211473e29..1c359607fb2 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -4,14 +4,13 @@ from typing import Any import pypck from homeassistant.components.scene import Scene -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, CONF_SCENE from . import LcnEntity from .const import ( CONF_CONNECTIONS, CONF_OUTPUTS, CONF_REGISTER, - CONF_SCENE, CONF_TRANSITION, DATA_LCN, OUTPUT_PORTS, diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 1cc51f400da..5244bac3b6b 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -11,7 +11,9 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_SCENE, CONF_SENSORS, + CONF_SOURCE, CONF_SWITCHES, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, @@ -32,11 +34,9 @@ from .const import ( CONF_OUTPUTS, CONF_REGISTER, CONF_REVERSE_TIME, - CONF_SCENE, CONF_SCENES, CONF_SETPOINT, CONF_SK_NUM_TRIES, - CONF_SOURCE, CONF_TRANSITION, DIM_MODES, DOMAIN, diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 4d4be5e1259..11932dccea8 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,12 +1,11 @@ """Support for LCN sensors.""" import pypck -from homeassistant.const import CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import CONF_ADDRESS, CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT from . import LcnEntity from .const import ( CONF_CONNECTIONS, - CONF_SOURCE, DATA_LCN, LED_PORTS, S0_INPUTS, diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 6f9cc25db99..5fe624b04bf 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -117,7 +117,6 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" - states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index ee396a7a9ee..c2d196196f9 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -29,12 +29,11 @@ class LGDevice(MediaPlayerEntity): def __init__(self, discovery_info): """Initialize the LG speakers.""" - self._host = discovery_info.get("host") - self._port = discovery_info.get("port") - properties = discovery_info.get("properties") - self._uuid = properties.get("UUID") + self._host = discovery_info["host"] + self._port = discovery_info["port"] + self._hostname = discovery_info["hostname"] - self._name = "" + self._name = self._hostname.split(".")[0] self._volume = 0 self._volume_min = 0 self._volume_max = 0 @@ -122,9 +121,9 @@ class LGDevice(MediaPlayerEntity): self._device.get_product_info() @property - def unique_id(self): - """Return the device's unique ID.""" - return self._uuid + def should_poll(self): + """No polling needed.""" + return False @property def name(self): diff --git a/homeassistant/components/life360/translations/ko.json b/homeassistant/components/life360/translations/ko.json index d419c5fdc02..d2ebd7c674f 100644 --- a/homeassistant/components/life360/translations/ko.json +++ b/homeassistant/components/life360/translations/ko.json @@ -1,10 +1,17 @@ { "config": { + "abort": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, "create_entry": { "default": "\uace0\uae09 \uc635\uc158\uc744 \uc124\uc815\ud558\ub824\uba74 [Life360 \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "error": { - "invalid_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/life360/translations/nl.json b/homeassistant/components/life360/translations/nl.json index c3b667722d0..612b0d5c4f7 100644 --- a/homeassistant/components/life360/translations/nl.json +++ b/homeassistant/components/life360/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "invalid_auth": "Ongeldige authenticatie" + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" }, "create_entry": { "default": "Om geavanceerde opties in te stellen, zie [Life360 documentatie]({docs_url})." @@ -9,7 +10,8 @@ "error": { "already_configured": "Account is al geconfigureerd", "invalid_auth": "Ongeldige authenticatie", - "invalid_username": "Ongeldige gebruikersnaam" + "invalid_username": "Ongeldige gebruikersnaam", + "unknown": "Onverwachte fout" }, "step": { "user": { diff --git a/homeassistant/components/life360/translations/ru.json b/homeassistant/components/life360/translations/ru.json index 2de2f63dbd6..5b5934fbb42 100644 --- a/homeassistant/components/life360/translations/ru.json +++ b/homeassistant/components/life360/translations/ru.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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." }, "create_entry": { @@ -9,7 +9,7 @@ }, "error": { "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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d.", "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/lifx/light.py b/homeassistant/components/lifx/light.py index e775b5623d3..f06a7720bb2 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -608,9 +608,13 @@ class LIFXLight(LightEntity): if not self.is_on: if power_off: await self.set_power(ack, False) - if hsbk: + # If fading on with color, set color immediately + if hsbk and power_on: await self.set_color(ack, hsbk, kwargs) - if power_on: + await self.set_power(ack, True, duration=fade) + elif hsbk: + await self.set_color(ack, hsbk, kwargs, duration=fade) + elif power_on: await self.set_power(ack, True, duration=fade) else: if power_on: diff --git a/homeassistant/components/lifx/translations/ko.json b/homeassistant/components/lifx/translations/ko.json index 040ac405e2d..34bec9c3aee 100644 --- a/homeassistant/components/lifx/translations/ko.json +++ b/homeassistant/components/lifx/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "LIFX \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 LIFX \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c46b7568b59..55476c754f2 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,4 +1,6 @@ """Provides functionality to interact with lights.""" +from __future__ import annotations + import csv import dataclasses from datetime import timedelta @@ -327,7 +329,7 @@ class Profile: ) @classmethod - def from_csv_row(cls, csv_row: List[str]) -> "Profile": + def from_csv_row(cls, csv_row: List[str]) -> Profile: """Create profile from a CSV row tuple.""" return cls(*cls.SCHEMA(csv_row)) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index a2b71f5632b..fe96f3a6777 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -1,127 +1,634 @@ # Describes the format for available light services turn_on: - description: Turn a light on. + name: Turn on + description: > + Turn on one or more lights and adjust properties of the light, even when + they are turned on already. + target: fields: - entity_id: - description: Name(s) of entities to turn on - example: "light.kitchen" - transition: - description: Duration in seconds it takes to get to next state - example: 60 - rgb_color: - description: Color for the light in RGB-format. - example: "[255, 100, 100]" - color_name: - description: A human readable color name. - example: "red" - hs_color: - description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. - example: "[300, 70]" - xy_color: - description: Color for the light in XY-format. - example: "[0.52, 0.43]" - color_temp: - description: Color temperature for the light in mireds. - example: 250 - kelvin: - description: Color temperature for the light in Kelvin. - example: 4000 - white_value: - description: Number between 0..255 indicating level of white. - example: "250" - brightness: - description: Number between 0..255 indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. - example: 120 - brightness_pct: - description: Number between 0..100 indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. - example: 47 - brightness_step: - description: Change brightness by an amount. Should be between -255..255. - example: -25.5 - brightness_step_pct: - description: Change brightness by a percentage. Should be between -100..100. - example: -10 - profile: - description: Name of a light profile to use. - example: relax - flash: - description: If the light should flash. Valid values are short and long. - example: short - values: - - short - - long - effect: - description: Light effect. - example: random - values: - - colorloop - - random - -turn_off: - description: Turn a light off. - fields: - entity_id: - description: Name(s) of entities to turn off. - example: "light.kitchen" transition: + name: Transition description: Duration in seconds it takes to get to next state. example: 60 - flash: - description: If the light should flash. Valid values are short and long. - example: short - values: - - short - - long - -toggle: - description: Toggles a light. - fields: - entity_id: - description: Name(s) of entities to turn on - example: "light.kitchen" - transition: - description: Duration in seconds it takes to get to next state - example: 60 + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider rgb_color: + name: RGB-color description: Color for the light in RGB-format. + advanced: true example: "[255, 100, 100]" + selector: + object: color_name: + name: Color name description: A human readable color name. + advanced: true example: "red" + selector: + select: + options: + - "homeassistant" + - "aliceblue" + - "antiquewhite" + - "aqua" + - "aquamarine" + - "azure" + - "beige" + - "bisque" + - "black" + - "blanchedalmond" + - "blue" + - "blueviolet" + - "brown" + - "burlywood" + - "cadetblue" + - "chartreuse" + - "chocolate" + - "coral" + - "cornflowerblue" + - "cornsilk" + - "crimson" + - "cyan" + - "darkblue" + - "darkcyan" + - "darkgoldenrod" + - "darkgray" + - "darkgreen" + - "darkgrey" + - "darkkhaki" + - "darkmagenta" + - "darkolivegreen" + - "darkorange" + - "darkorchid" + - "darkred" + - "darksalmon" + - "darkseagreen" + - "darkslateblue" + - "darkslategray" + - "darkslategrey" + - "darkturquoise" + - "darkviolet" + - "deeppink" + - "deepskyblue" + - "dimgray" + - "dimgrey" + - "dodgerblue" + - "firebrick" + - "floralwhite" + - "forestgreen" + - "fuchsia" + - "gainsboro" + - "ghostwhite" + - "gold" + - "goldenrod" + - "gray" + - "green" + - "greenyellow" + - "grey" + - "honeydew" + - "hotpink" + - "indianred" + - "indigo" + - "ivory" + - "khaki" + - "lavender" + - "lavenderblush" + - "lawngreen" + - "lemonchiffon" + - "lightblue" + - "lightcoral" + - "lightcyan" + - "lightgoldenrodyellow" + - "lightgray" + - "lightgreen" + - "lightgrey" + - "lightpink" + - "lightsalmon" + - "lightseagreen" + - "lightskyblue" + - "lightslategray" + - "lightslategrey" + - "lightsteelblue" + - "lightyellow" + - "lime" + - "limegreen" + - "linen" + - "magenta" + - "maroon" + - "mediumaquamarine" + - "mediumblue" + - "mediumorchid" + - "mediumpurple" + - "mediumseagreen" + - "mediumslateblue" + - "mediumspringgreen" + - "mediumturquoise" + - "mediumvioletred" + - "midnightblue" + - "mintcream" + - "mistyrose" + - "moccasin" + - "navajowhite" + - "navy" + - "navyblue" + - "oldlace" + - "olive" + - "olivedrab" + - "orange" + - "orangered" + - "orchid" + - "palegoldenrod" + - "palegreen" + - "paleturquoise" + - "palevioletred" + - "papayawhip" + - "peachpuff" + - "peru" + - "pink" + - "plum" + - "powderblue" + - "purple" + - "red" + - "rosybrown" + - "royalblue" + - "saddlebrown" + - "salmon" + - "sandybrown" + - "seagreen" + - "seashell" + - "sienna" + - "silver" + - "skyblue" + - "slateblue" + - "slategray" + - "slategrey" + - "snow" + - "springgreen" + - "steelblue" + - "tan" + - "teal" + - "thistle" + - "tomato" + - "turquoise" + - "violet" + - "wheat" + - "white" + - "whitesmoke" + - "yellow" + - "yellowgreen" hs_color: - description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + name: Hue/Sat color + description: + Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + advanced: true example: "[300, 70]" + selector: + object: xy_color: + name: XY-color description: Color for the light in XY-format. + advanced: true example: "[0.52, 0.43]" + selector: + object: color_temp: + name: Color temperature (mireds) description: Color temperature for the light in mireds. + advanced: true example: 250 + selector: + number: + min: 153 + max: 500 + step: 1 + unit_of_measurement: mireds + mode: slider kelvin: + name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + advanced: true example: 4000 + selector: + number: + min: 2000 + max: 6500 + step: 100 + unit_of_measurement: K + mode: slider white_value: + name: White level description: Number between 0..255 indicating level of white. + advanced: true example: "250" + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider brightness: - description: Number between 0..255 indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + name: Brightness value + description: + Number between 0..255 indicating brightness, where 0 turns the light + off, 1 is the minimum brightness and 255 is the maximum brightness + supported by the light. + advanced: true example: 120 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider brightness_pct: - description: Number between 0..100 indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + name: Brightness + description: + Number between 0..100 indicating percentage of full brightness, where 0 + turns the light off, 1 is the minimum brightness and 100 is the maximum + brightness supported by the light. example: 47 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + brightness_step: + name: Brightness step value + description: Change brightness by an amount. Should be between -255..255. + advanced: true + example: -25.5 + selector: + number: + min: -225 + max: 255 + step: 1 + mode: slider + brightness_step_pct: + name: Brightness step + description: + Change brightness by a percentage. Should be between -100..100. + example: -10 + selector: + number: + min: -100 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider profile: + name: Profile description: Name of a light profile to use. + advanced: true example: relax + selector: + text: flash: + name: Flash description: If the light should flash. Valid values are short and long. + advanced: true example: short values: - short - long + selector: + select: + options: + - long + - short effect: + name: Effect description: Light effect. example: random values: - colorloop - random + selector: + text: + +turn_off: + name: Turn off + description: Turns off one or more lights. + target: + fields: + transition: + name: Transition + description: Duration in seconds it takes to get to next state. + example: 60 + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider + flash: + name: Flash + description: If the light should flash. Valid values are short and long. + advanced: true + example: short + values: + - short + - long + selector: + select: + options: + - long + - short + +toggle: + name: Toggle + description: > + Toggles one or more lights, from on to off, or, off to on, based on their + current state. + target: + fields: + transition: + name: Transition + description: Duration in seconds it takes to get to next state. + example: 60 + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider + rgb_color: + name: RGB-color + description: Color for the light in RGB-format. + advanced: true + example: "[255, 100, 100]" + selector: + object: + color_name: + name: Color name + description: A human readable color name. + advanced: true + example: "red" + selector: + select: + options: + - "homeassistant" + - "aliceblue" + - "antiquewhite" + - "aqua" + - "aquamarine" + - "azure" + - "beige" + - "bisque" + - "black" + - "blanchedalmond" + - "blue" + - "blueviolet" + - "brown" + - "burlywood" + - "cadetblue" + - "chartreuse" + - "chocolate" + - "coral" + - "cornflowerblue" + - "cornsilk" + - "crimson" + - "cyan" + - "darkblue" + - "darkcyan" + - "darkgoldenrod" + - "darkgray" + - "darkgreen" + - "darkgrey" + - "darkkhaki" + - "darkmagenta" + - "darkolivegreen" + - "darkorange" + - "darkorchid" + - "darkred" + - "darksalmon" + - "darkseagreen" + - "darkslateblue" + - "darkslategray" + - "darkslategrey" + - "darkturquoise" + - "darkviolet" + - "deeppink" + - "deepskyblue" + - "dimgray" + - "dimgrey" + - "dodgerblue" + - "firebrick" + - "floralwhite" + - "forestgreen" + - "fuchsia" + - "gainsboro" + - "ghostwhite" + - "gold" + - "goldenrod" + - "gray" + - "green" + - "greenyellow" + - "grey" + - "honeydew" + - "hotpink" + - "indianred" + - "indigo" + - "ivory" + - "khaki" + - "lavender" + - "lavenderblush" + - "lawngreen" + - "lemonchiffon" + - "lightblue" + - "lightcoral" + - "lightcyan" + - "lightgoldenrodyellow" + - "lightgray" + - "lightgreen" + - "lightgrey" + - "lightpink" + - "lightsalmon" + - "lightseagreen" + - "lightskyblue" + - "lightslategray" + - "lightslategrey" + - "lightsteelblue" + - "lightyellow" + - "lime" + - "limegreen" + - "linen" + - "magenta" + - "maroon" + - "mediumaquamarine" + - "mediumblue" + - "mediumorchid" + - "mediumpurple" + - "mediumseagreen" + - "mediumslateblue" + - "mediumspringgreen" + - "mediumturquoise" + - "mediumvioletred" + - "midnightblue" + - "mintcream" + - "mistyrose" + - "moccasin" + - "navajowhite" + - "navy" + - "navyblue" + - "oldlace" + - "olive" + - "olivedrab" + - "orange" + - "orangered" + - "orchid" + - "palegoldenrod" + - "palegreen" + - "paleturquoise" + - "palevioletred" + - "papayawhip" + - "peachpuff" + - "peru" + - "pink" + - "plum" + - "powderblue" + - "purple" + - "red" + - "rosybrown" + - "royalblue" + - "saddlebrown" + - "salmon" + - "sandybrown" + - "seagreen" + - "seashell" + - "sienna" + - "silver" + - "skyblue" + - "slateblue" + - "slategray" + - "slategrey" + - "snow" + - "springgreen" + - "steelblue" + - "tan" + - "teal" + - "thistle" + - "tomato" + - "turquoise" + - "violet" + - "wheat" + - "white" + - "whitesmoke" + - "yellow" + - "yellowgreen" + hs_color: + name: Hue/Sat color + description: + Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + advanced: true + example: "[300, 70]" + selector: + object: + xy_color: + name: XY-color + description: Color for the light in XY-format. + advanced: true + example: "[0.52, 0.43]" + selector: + object: + color_temp: + name: Color temperature (mireds) + description: Color temperature for the light in mireds. + advanced: true + example: 250 + selector: + number: + min: 153 + max: 500 + step: 1 + unit_of_measurement: mireds + mode: slider + kelvin: + name: Color temperature (Kelvin) + description: Color temperature for the light in Kelvin. + advanced: true + example: 4000 + selector: + number: + min: 2000 + max: 6500 + step: 100 + unit_of_measurement: K + mode: slider + white_value: + name: White level + description: Number between 0..255 indicating level of white. + advanced: true + example: "250" + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider + brightness: + name: Brightness value + description: + Number between 0..255 indicating brightness, where 0 turns the light + off, 1 is the minimum brightness and 255 is the maximum brightness + supported by the light. + advanced: true + example: 120 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider + brightness_pct: + name: Brightness + description: + Number between 0..100 indicating percentage of full brightness, where 0 + turns the light off, 1 is the minimum brightness and 100 is the maximum + brightness supported by the light. + example: 47 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + profile: + name: Profile + description: Name of a light profile to use. + advanced: true + example: relax + selector: + text: + flash: + name: Flash + description: If the light should flash. Valid values are short and long. + advanced: true + example: short + values: + - short + - long + selector: + select: + options: + - long + - short + effect: + name: Effect + description: Light effect. + example: random + values: + - colorloop + - random + selector: + text: diff --git a/homeassistant/components/light/translations/sv.json b/homeassistant/components/light/translations/sv.json index 0d0e29a87ed..d5f0bdaf767 100644 --- a/homeassistant/components/light/translations/sv.json +++ b/homeassistant/components/light/translations/sv.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Minska ljusstyrkan f\u00f6r {entity_name}", + "brightness_increase": "\u00d6ka ljusstyrkan f\u00f6r {entity_name}", "toggle": "V\u00e4xla {entity_name}", "turn_off": "St\u00e4ng av {entity_name}", "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 9977bb9bdb4..0c8f59c4127 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -1,49 +1,86 @@ """Support for the LiteJet lighting system.""" -from pylitejet import LiteJet +import asyncio +import logging + +import pylitejet +from serial import SerialException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PORT -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -CONF_EXCLUDE_NAMES = "exclude_names" -CONF_INCLUDE_SWITCHES = "include_switches" +from .const import CONF_EXCLUDE_NAMES, CONF_INCLUDE_SWITCHES, DOMAIN, PLATFORMS -DOMAIN = "litejet" +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_EXCLUDE_NAMES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_EXCLUDE_NAMES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) def setup(hass, config): """Set up the LiteJet component.""" + if DOMAIN in config and not hass.config_entries.async_entries(DOMAIN): + # No config entry exists and configuration.yaml config exists, trigger the import flow. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) + return True - url = config[DOMAIN].get(CONF_PORT) - hass.data["litejet_system"] = LiteJet(url) - hass.data["litejet_config"] = config[DOMAIN] +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LiteJet via a config entry.""" + port = entry.data[CONF_PORT] - discovery.load_platform(hass, "light", DOMAIN, {}, config) - if config[DOMAIN].get(CONF_INCLUDE_SWITCHES): - discovery.load_platform(hass, "switch", DOMAIN, {}, config) - discovery.load_platform(hass, "scene", DOMAIN, {}, config) + try: + system = pylitejet.LiteJet(port) + except SerialException as ex: + _LOGGER.error("Error connecting to the LiteJet MCP at %s", port, exc_info=ex) + raise ConfigEntryNotReady from ex + + hass.data[DOMAIN] = system + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) return True -def is_ignored(hass, name): - """Determine if a load, switch, or scene should be ignored.""" - for prefix in hass.data["litejet_config"].get(CONF_EXCLUDE_NAMES, []): - if name.startswith(prefix): - return True - return False +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a LiteJet config entry.""" + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].close() + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py new file mode 100644 index 00000000000..e1c7d8ab7b9 --- /dev/null +++ b/homeassistant/components/litejet/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow for the LiteJet lighting system.""" +import logging +from typing import Any, Dict, Optional + +import pylitejet +from serial import SerialException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PORT + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """LiteJet config flow.""" + + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Create a LiteJet config entry based upon user input.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + if user_input is not None: + port = user_input[CONF_PORT] + + await self.async_set_unique_id(port) + self._abort_if_unique_id_configured() + + try: + system = pylitejet.LiteJet(port) + system.close() + except SerialException: + errors[CONF_PORT] = "open_failed" + else: + return self.async_create_entry( + title=port, + data={CONF_PORT: port}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_PORT): str}), + errors=errors, + ) + + async def async_step_import(self, import_data): + """Import litejet config from configuration.yaml.""" + return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) diff --git a/homeassistant/components/litejet/const.py b/homeassistant/components/litejet/const.py new file mode 100644 index 00000000000..8e27aa3a0a7 --- /dev/null +++ b/homeassistant/components/litejet/const.py @@ -0,0 +1,8 @@ +"""LiteJet constants.""" + +DOMAIN = "litejet" + +CONF_EXCLUDE_NAMES = "exclude_names" +CONF_INCLUDE_SWITCHES = "include_switches" + +PLATFORMS = ["light", "switch", "scene"] diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index efc6830d775..27ce904cc2c 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -1,43 +1,53 @@ """Support for LiteJet lights.""" import logging -from homeassistant.components import litejet from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, LightEntity, ) +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_NUMBER = "number" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up lights for the LiteJet platform.""" - litejet_ = hass.data["litejet_system"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" - devices = [] - for i in litejet_.loads(): - name = litejet_.get_load_name(i) - if not litejet.is_ignored(hass, name): - devices.append(LiteJetLight(hass, litejet_, i, name)) - add_entities(devices, True) + system = hass.data[DOMAIN] + + def get_entities(system): + entities = [] + for i in system.loads(): + name = system.get_load_name(i) + entities.append(LiteJetLight(config_entry.entry_id, system, i, name)) + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities, system), True) class LiteJetLight(LightEntity): """Representation of a single LiteJet light.""" - def __init__(self, hass, lj, i, name): + def __init__(self, entry_id, lj, i, name): """Initialize a LiteJet light.""" - self._hass = hass + self._entry_id = entry_id self._lj = lj self._index = i self._brightness = 0 self._name = name - lj.on_load_activated(i, self._on_load_changed) - lj.on_load_deactivated(i, self._on_load_changed) + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + self._lj.on_load_activated(self._index, self._on_load_changed) + self._lj.on_load_deactivated(self._index, self._on_load_changed) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + self._lj.unsubscribe(self._on_load_changed) def _on_load_changed(self): """Handle state changes.""" @@ -54,6 +64,11 @@ class LiteJetLight(LightEntity): """Return the light's name.""" return self._name + @property + def unique_id(self): + """Return a unique identifier for this light.""" + return f"{self._entry_id}_{self._index}" + @property def brightness(self): """Return the light's brightness.""" diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 1e469370b43..e23e5ac2964 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -2,6 +2,7 @@ "domain": "litejet", "name": "LiteJet", "documentation": "https://www.home-assistant.io/integrations/litejet", - "requirements": ["pylitejet==0.1"], - "codeowners": [] + "requirements": ["pylitejet==0.3.0"], + "codeowners": ["@joncar"], + "config_flow": true } diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index 3311b8d86a0..daadfce90dc 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -1,29 +1,37 @@ """Support for LiteJet scenes.""" +import logging from typing import Any -from homeassistant.components import litejet from homeassistant.components.scene import Scene +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + ATTR_NUMBER = "number" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up scenes for the LiteJet platform.""" - litejet_ = hass.data["litejet_system"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" - devices = [] - for i in litejet_.scenes(): - name = litejet_.get_scene_name(i) - if not litejet.is_ignored(hass, name): - devices.append(LiteJetScene(litejet_, i, name)) - add_entities(devices) + system = hass.data[DOMAIN] + + def get_entities(system): + entities = [] + for i in system.scenes(): + name = system.get_scene_name(i) + entities.append(LiteJetScene(config_entry.entry_id, system, i, name)) + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities, system), True) class LiteJetScene(Scene): """Representation of a single LiteJet scene.""" - def __init__(self, lj, i, name): + def __init__(self, entry_id, lj, i, name): """Initialize the scene.""" + self._entry_id = entry_id self._lj = lj self._index = i self._name = name @@ -33,6 +41,11 @@ class LiteJetScene(Scene): """Return the name of the scene.""" return self._name + @property + def unique_id(self): + """Return a unique identifier for this scene.""" + return f"{self._entry_id}_{self._index}" + @property def device_state_attributes(self): """Return the device-specific state attributes.""" @@ -41,3 +54,8 @@ class LiteJetScene(Scene): def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self._lj.activate_scene(self._index) + + @property + def entity_registry_enabled_default(self) -> bool: + """Scenes are only enabled by explicit user choice.""" + return False diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json new file mode 100644 index 00000000000..79c4ed5f329 --- /dev/null +++ b/homeassistant/components/litejet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect To LiteJet", + "description": "Connect the LiteJet's RS232-2 port to your computer and enter the path to the serial port device.\n\nThe LiteJet MCP must be configured for 19.2 K baud, 8 data bits, 1 stop bit, no parity, and to transmit a 'CR' after each response.", + "data": { + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "open_failed": "Cannot open the specified serial port." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index a734dc46d3e..b782a4a9d98 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -1,39 +1,50 @@ """Support for LiteJet switch.""" import logging -from homeassistant.components import litejet from homeassistant.components.switch import SwitchEntity +from .const import DOMAIN + ATTR_NUMBER = "number" _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the LiteJet switch platform.""" - litejet_ = hass.data["litejet_system"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" - devices = [] - for i in litejet_.button_switches(): - name = litejet_.get_switch_name(i) - if not litejet.is_ignored(hass, name): - devices.append(LiteJetSwitch(hass, litejet_, i, name)) - add_entities(devices, True) + system = hass.data[DOMAIN] + + def get_entities(system): + entities = [] + for i in system.button_switches(): + name = system.get_switch_name(i) + entities.append(LiteJetSwitch(config_entry.entry_id, system, i, name)) + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities, system), True) class LiteJetSwitch(SwitchEntity): """Representation of a single LiteJet switch.""" - def __init__(self, hass, lj, i, name): + def __init__(self, entry_id, lj, i, name): """Initialize a LiteJet switch.""" - self._hass = hass + self._entry_id = entry_id self._lj = lj self._index = i self._state = False self._name = name - lj.on_switch_pressed(i, self._on_switch_pressed) - lj.on_switch_released(i, self._on_switch_released) + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + self._lj.on_switch_pressed(self._index, self._on_switch_pressed) + self._lj.on_switch_released(self._index, self._on_switch_released) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + self._lj.unsubscribe(self._on_switch_pressed) + self._lj.unsubscribe(self._on_switch_released) def _on_switch_pressed(self): _LOGGER.debug("Updating pressed for %s", self._name) @@ -50,6 +61,11 @@ class LiteJetSwitch(SwitchEntity): """Return the name of the switch.""" return self._name + @property + def unique_id(self): + """Return a unique identifier for this switch.""" + return f"{self._entry_id}_{self._index}" + @property def is_on(self): """Return if the switch is pressed.""" @@ -72,3 +88,8 @@ class LiteJetSwitch(SwitchEntity): def turn_off(self, **kwargs): """Release the switch.""" self._lj.release_switch(self._index) + + @property + def entity_registry_enabled_default(self) -> bool: + """Switches are only enabled by explicit user choice.""" + return False diff --git a/homeassistant/components/litejet/translations/ca.json b/homeassistant/components/litejet/translations/ca.json new file mode 100644 index 00000000000..39e2a56dc4d --- /dev/null +++ b/homeassistant/components/litejet/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "open_failed": "No s'ha pogut obrir el port s\u00e8rie especificat." + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "Connecta el port RS232-2 LiteJet a l'ordinador i introdueix la ruta al port s\u00e8rie del dispositiu.\n\nEl LiteJet MCP ha d'estar configurat amb una velocitat de 19.2 k baudis, 8 bits de dades, 1 bit de parada, sense paritat i ha de transmetre un 'CR' despr\u00e9s de cada resposta.", + "title": "Connexi\u00f3 amb LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/de.json b/homeassistant/components/litejet/translations/de.json new file mode 100644 index 00000000000..492314e5cc6 --- /dev/null +++ b/homeassistant/components/litejet/translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "step": { + "user": { + "data": { + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/en.json b/homeassistant/components/litejet/translations/en.json new file mode 100644 index 00000000000..e09b20dc9f2 --- /dev/null +++ b/homeassistant/components/litejet/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "open_failed": "Cannot open the specified serial port." + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "Connect the LiteJet's RS232-2 port to your computer and enter the path to the serial port device.\n\nThe LiteJet MCP must be configured for 19.2 K baud, 8 data bits, 1 stop bit, no parity, and to transmit a 'CR' after each response.", + "title": "Connect To LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/es.json b/homeassistant/components/litejet/translations/es.json new file mode 100644 index 00000000000..b0641022bf0 --- /dev/null +++ b/homeassistant/components/litejet/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "open_failed": "No se puede abrir el puerto serie especificado." + }, + "step": { + "user": { + "data": { + "port": "Puerto" + }, + "description": "Conecte el puerto RS232-2 del LiteJet a su computadora e ingrese la ruta al dispositivo del puerto serial. \n\nEl LiteJet MCP debe configurarse para 19,2 K baudios, 8 bits de datos, 1 bit de parada, sin paridad y para transmitir un 'CR' despu\u00e9s de cada respuesta.", + "title": "Conectarse a LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/et.json b/homeassistant/components/litejet/translations/et.json new file mode 100644 index 00000000000..6e50b5dcdf3 --- /dev/null +++ b/homeassistant/components/litejet/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "error": { + "open_failed": "valitud jadaporti ei saa avada." + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "\u00dchenda LiteJeti RS232-2 port arvutiga ja sisesta jadapordi seadme tee.\n\nLiteJet MCP peab olema konfigureeritud: 19200 boodi, 8 andmebitti, 1 stopp bitt, paarsus puudub ja edastada \"CR\" p\u00e4rast iga vastust.", + "title": "Loo \u00fchendus LiteJetiga" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/fr.json b/homeassistant/components/litejet/translations/fr.json new file mode 100644 index 00000000000..89459d1829f --- /dev/null +++ b/homeassistant/components/litejet/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "open_failed": "Impossible d'ouvrir le port s\u00e9rie sp\u00e9cifi\u00e9." + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "Connectez le port RS232-2 du LiteJet \u00e0 votre ordinateur et entrez le chemin d'acc\u00e8s au p\u00e9riph\u00e9rique de port s\u00e9rie. \n\n Le LiteJet MCP doit \u00eatre configur\u00e9 pour 19,2 K bauds, 8 bits de donn\u00e9es, 1 bit d'arr\u00eat, sans parit\u00e9 et pour transmettre un \u00abCR\u00bb apr\u00e8s chaque r\u00e9ponse.", + "title": "Connectez-vous \u00e0 LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/he.json b/homeassistant/components/litejet/translations/he.json new file mode 100644 index 00000000000..a06c89f1d2a --- /dev/null +++ b/homeassistant/components/litejet/translations/he.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "\u05e4\u05d5\u05e8\u05d8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/it.json b/homeassistant/components/litejet/translations/it.json new file mode 100644 index 00000000000..5b3dc46753d --- /dev/null +++ b/homeassistant/components/litejet/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "open_failed": "Impossibile aprire la porta seriale specificata." + }, + "step": { + "user": { + "data": { + "port": "Porta" + }, + "description": "Collega la porta RS232-2 del LiteJet al tuo computer e inserisci il percorso del dispositivo della porta seriale. \n\nL'MCP LiteJet deve essere configurato per 19,2 K baud, 8 bit di dati, 1 bit di stop, nessuna parit\u00e0 e per trasmettere un \"CR\" dopo ogni risposta.", + "title": "Connetti a LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/nl.json b/homeassistant/components/litejet/translations/nl.json new file mode 100644 index 00000000000..f16f25a3987 --- /dev/null +++ b/homeassistant/components/litejet/translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "error": { + "open_failed": "Kan de opgegeven seri\u00eble poort niet openen." + }, + "step": { + "user": { + "data": { + "port": "Poort" + }, + "title": "Maak verbinding met LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/no.json b/homeassistant/components/litejet/translations/no.json new file mode 100644 index 00000000000..d3206ca2897 --- /dev/null +++ b/homeassistant/components/litejet/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "open_failed": "Kan ikke \u00e5pne den angitte serielle porten" + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "Koble LiteJets RS232-2-port til datamaskinen og skriv stien til den serielle portenheten. \n\n LiteJet MCP m\u00e5 konfigureres for 19,2 K baud, 8 databiter, 1 stoppbit, ingen paritet, og for \u00e5 overf\u00f8re en 'CR' etter hvert svar.", + "title": "Koble til LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/pl.json b/homeassistant/components/litejet/translations/pl.json new file mode 100644 index 00000000000..20e5d68288d --- /dev/null +++ b/homeassistant/components/litejet/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "open_failed": "Nie mo\u017cna otworzy\u0107 okre\u015blonego portu szeregowego." + }, + "step": { + "user": { + "data": { + "port": "Port" + }, + "description": "Pod\u0142\u0105cz port RS232-2 LiteJet do komputera i wprowad\u017a \u015bcie\u017ck\u0119 do urz\u0105dzenia portu szeregowego. \n\nLiteJet MCP musi by\u0107 skonfigurowany dla szybko\u015bci 19,2K, 8 bit\u00f3w danych, 1 bit stopu, brak parzysto\u015bci i przesy\u0142anie \u201eCR\u201d po ka\u017cdej odpowiedzi.", + "title": "Po\u0142\u0105czenie z LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/ru.json b/homeassistant/components/litejet/translations/ru.json new file mode 100644 index 00000000000..c90e6956301 --- /dev/null +++ b/homeassistant/components/litejet/translations/ru.json @@ -0,0 +1,19 @@ +{ + "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": { + "open_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044c \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442." + }, + "step": { + "user": { + "data": { + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u043f\u043e\u0440\u0442 RS232-2 LiteJet \u043a \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0443, \u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u043c\u0443 \u043f\u043e\u0440\u0442\u0443. \n\nLiteJet MCP \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u043d\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c 19,2 \u041a\u0431\u043e\u0434, 8 \u0431\u0438\u0442 \u0434\u0430\u043d\u043d\u044b\u0445, 1 \u0441\u0442\u043e\u043f\u043e\u0432\u044b\u0439 \u0431\u0438\u0442, \u0431\u0435\u0437 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0447\u0435\u0442\u043d\u043e\u0441\u0442\u0438 \u0438 \u043d\u0430 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0443 'CR' \u043f\u043e\u0441\u043b\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043e\u0442\u0432\u0435\u0442\u0430.", + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/tr.json b/homeassistant/components/litejet/translations/tr.json new file mode 100644 index 00000000000..de4ea12cb6f --- /dev/null +++ b/homeassistant/components/litejet/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "LiteJet'e Ba\u011flan\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/zh-Hant.json b/homeassistant/components/litejet/translations/zh-Hant.json new file mode 100644 index 00000000000..8a268f3db49 --- /dev/null +++ b/homeassistant/components/litejet/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "error": { + "open_failed": "\u7121\u6cd5\u958b\u555f\u6307\u5b9a\u7684\u5e8f\u5217\u57e0" + }, + "step": { + "user": { + "data": { + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u9023\u7dda\u81f3\u96fb\u8166\u4e0a\u7684 LiteJet RS232-2 \u57e0\uff0c\u4e26\u8f38\u5165\u5e8f\u5217\u57e0\u88dd\u7f6e\u7684\u8def\u5f91\u3002\n\nLiteJet MCP \u5fc5\u9808\u8a2d\u5b9a\u70ba\u901a\u8a0a\u901f\u7387 19.2 K baud\u30018 \u6578\u64da\u4f4d\u5143\u30011 \u505c\u6b62\u4f4d\u5143\u3001\u7121\u540c\u4f4d\u4f4d\u5143\u4e26\u65bc\u6bcf\u500b\u56de\u5fa9\u5f8c\u50b3\u9001 'CR'\u3002", + "title": "\u9023\u7dda\u81f3 LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 0b0117465df..71841d9c4fd 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -1,4 +1,6 @@ """Trigger an automation when a LiteJet switch is released.""" +from typing import Callable + import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -7,7 +9,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time import homeassistant.util.dt as dt_util -# mypy: allow-untyped-defs, no-check-untyped-defs +from .const import DOMAIN CONF_NUMBER = "number" CONF_HELD_MORE_THAN = "held_more_than" @@ -33,7 +35,7 @@ async def async_attach_trigger(hass, config, action, automation_info): held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) pressed_time = None - cancel_pressed_more_than = None + cancel_pressed_more_than: Callable = None job = HassJob(action) @callback @@ -91,12 +93,15 @@ async def async_attach_trigger(hass, config, action, automation_info): ): hass.add_job(call_action) - hass.data["litejet_system"].on_switch_pressed(number, pressed) - hass.data["litejet_system"].on_switch_released(number, released) + system = hass.data[DOMAIN] + + system.on_switch_pressed(number, pressed) + system.on_switch_released(number, released) @callback def async_remove(): """Remove all subscriptions used for this trigger.""" - return + system.unsubscribe(pressed) + system.unsubscribe(released) return async_remove diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py new file mode 100644 index 00000000000..19e76b9bb19 --- /dev/null +++ b/homeassistant/components/litterrobot/__init__.py @@ -0,0 +1,54 @@ +"""The Litter-Robot integration.""" +import asyncio + +from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .hub import LitterRobotHub + +PLATFORMS = ["sensor", "switch", "vacuum"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Litter-Robot component.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Litter-Robot from a config entry.""" + hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data) + try: + await hub.login(load_robots=True) + except LitterRobotLoginException: + return False + except LitterRobotException as ex: + raise ConfigEntryNotReady from ex + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py new file mode 100644 index 00000000000..d6c92d8dad6 --- /dev/null +++ b/homeassistant/components/litterrobot/config_flow.py @@ -0,0 +1,51 @@ +"""Config flow for Litter-Robot integration.""" +import logging + +from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint:disable=unused-import +from .hub import LitterRobotHub + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Litter-Robot.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + for entry in self._async_current_entries(): + if entry.data[CONF_USERNAME] == user_input[CONF_USERNAME]: + return self.async_abort(reason="already_configured") + + hub = LitterRobotHub(self.hass, user_input) + try: + await hub.login() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + except LitterRobotLoginException: + errors["base"] = "invalid_auth" + except LitterRobotException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/litterrobot/const.py b/homeassistant/components/litterrobot/const.py new file mode 100644 index 00000000000..5ac889d9b73 --- /dev/null +++ b/homeassistant/components/litterrobot/const.py @@ -0,0 +1,2 @@ +"""Constants for the Litter-Robot integration.""" +DOMAIN = "litterrobot" diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py new file mode 100644 index 00000000000..0d0559140c7 --- /dev/null +++ b/homeassistant/components/litterrobot/hub.py @@ -0,0 +1,122 @@ +"""A wrapper 'hub' for the Litter-Robot API and base entity for common attributes.""" +from datetime import time, timedelta +import logging +from types import MethodType +from typing import Any, Optional + +from pylitterbot import Account, Robot +from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +REFRESH_WAIT_TIME = 12 +UPDATE_INTERVAL = 10 + + +class LitterRobotHub: + """A Litter-Robot hub wrapper class.""" + + def __init__(self, hass: HomeAssistant, data: dict): + """Initialize the Litter-Robot hub.""" + self._data = data + self.account = None + self.logged_in = False + + async def _async_update_data(): + """Update all device states from the Litter-Robot API.""" + await self.account.refresh_robots() + return True + + self.coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_data, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + async def login(self, load_robots: bool = False): + """Login to Litter-Robot.""" + self.logged_in = False + self.account = Account() + try: + await self.account.connect( + username=self._data[CONF_USERNAME], + password=self._data[CONF_PASSWORD], + load_robots=load_robots, + ) + self.logged_in = True + return self.logged_in + except LitterRobotLoginException as ex: + _LOGGER.error("Invalid credentials") + raise ex + except LitterRobotException as ex: + _LOGGER.error("Unable to connect to Litter-Robot API") + raise ex + + +class LitterRobotEntity(CoordinatorEntity): + """Generic Litter-Robot entity representing common data and methods.""" + + def __init__(self, robot: Robot, entity_type: str, hub: LitterRobotHub): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(hub.coordinator) + self.robot = robot + self.entity_type = entity_type if entity_type else "" + self.hub = hub + + @property + def name(self): + """Return the name of this entity.""" + return f"{self.robot.name} {self.entity_type}" + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self.robot.serial}-{self.entity_type}" + + @property + def device_info(self): + """Return the device information for a Litter-Robot.""" + model = "Litter-Robot 3 Connect" + if not self.robot.serial.startswith("LR3C"): + model = "Other Litter-Robot Connected Device" + return { + "identifiers": {(DOMAIN, self.robot.serial)}, + "name": self.robot.name, + "manufacturer": "Litter-Robot", + "model": model, + } + + async def perform_action_and_refresh(self, action: MethodType, *args: Any): + """Perform an action and initiates a refresh of the robot data after a few seconds.""" + await action(*args) + async_call_later( + self.hass, REFRESH_WAIT_TIME, self.hub.coordinator.async_request_refresh + ) + + @staticmethod + def parse_time_at_default_timezone(time_str: str) -> Optional[time]: + """Parse a time string and add default timezone.""" + parsed_time = dt_util.parse_time(time_str) + + if parsed_time is None: + return None + + return time( + hour=parsed_time.hour, + minute=parsed_time.minute, + second=parsed_time.second, + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json new file mode 100644 index 00000000000..1c6ac7274bf --- /dev/null +++ b/homeassistant/components/litterrobot/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "litterrobot", + "name": "Litter-Robot", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/litterrobot", + "requirements": ["pylitterbot==2021.2.5"], + "codeowners": ["@natekspencer"] +} diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py new file mode 100644 index 00000000000..2843660bcee --- /dev/null +++ b/homeassistant/components/litterrobot/sensor.py @@ -0,0 +1,54 @@ +"""Support for Litter-Robot sensors.""" +from homeassistant.const import PERCENTAGE +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .hub import LitterRobotEntity + +WASTE_DRAWER = "Waste Drawer" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Litter-Robot sensors using config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + for robot in hub.account.robots: + entities.append(LitterRobotSensor(robot, WASTE_DRAWER, hub)) + + if entities: + async_add_entities(entities, True) + + +class LitterRobotSensor(LitterRobotEntity, Entity): + """Litter-Robot sensors.""" + + @property + def state(self): + """Return the state.""" + return self.robot.waste_drawer_gauge + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return PERCENTAGE + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self.robot.waste_drawer_gauge <= 10: + return "mdi:gauge-empty" + if self.robot.waste_drawer_gauge < 50: + return "mdi:gauge-low" + if self.robot.waste_drawer_gauge <= 90: + return "mdi:gauge" + return "mdi:gauge-full" + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + "cycle_count": self.robot.cycle_count, + "cycle_capacity": self.robot.cycle_capacity, + "cycles_after_drawer_full": self.robot.cycles_after_drawer_full, + } diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/litterrobot/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py new file mode 100644 index 00000000000..b94b29a35e1 --- /dev/null +++ b/homeassistant/components/litterrobot/switch.py @@ -0,0 +1,68 @@ +"""Support for Litter-Robot switches.""" +from homeassistant.helpers.entity import ToggleEntity + +from .const import DOMAIN +from .hub import LitterRobotEntity + + +class LitterRobotNightLightModeSwitch(LitterRobotEntity, ToggleEntity): + """Litter-Robot Night Light Mode Switch.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self.robot.night_light_active + + @property + def icon(self): + """Return the icon.""" + return "mdi:lightbulb-on" if self.is_on else "mdi:lightbulb-off" + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self.perform_action_and_refresh(self.robot.set_night_light, True) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self.perform_action_and_refresh(self.robot.set_night_light, False) + + +class LitterRobotPanelLockoutSwitch(LitterRobotEntity, ToggleEntity): + """Litter-Robot Panel Lockout Switch.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self.robot.panel_lock_active + + @property + def icon(self): + """Return the icon.""" + return "mdi:lock" if self.is_on else "mdi:lock-open" + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self.perform_action_and_refresh(self.robot.set_panel_lockout, True) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self.perform_action_and_refresh(self.robot.set_panel_lockout, False) + + +ROBOT_SWITCHES = { + "Night Light Mode": LitterRobotNightLightModeSwitch, + "Panel Lockout": LitterRobotPanelLockoutSwitch, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Litter-Robot switches using config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + for robot in hub.account.robots: + for switch_type, switch_class in ROBOT_SWITCHES.items(): + entities.append(switch_class(robot, switch_type, hub)) + + if entities: + async_add_entities(entities, True) diff --git a/homeassistant/components/litterrobot/translations/ca.json b/homeassistant/components/litterrobot/translations/ca.json new file mode 100644 index 00000000000..9677f944330 --- /dev/null +++ b/homeassistant/components/litterrobot/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", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/de.json b/homeassistant/components/litterrobot/translations/de.json new file mode 100644 index 00000000000..0eee2778d05 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/en.json b/homeassistant/components/litterrobot/translations/en.json new file mode 100644 index 00000000000..cb0e7bed7ea --- /dev/null +++ b/homeassistant/components/litterrobot/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/et.json b/homeassistant/components/litterrobot/translations/et.json new file mode 100644 index 00000000000..ce02ca14929 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/fr.json b/homeassistant/components/litterrobot/translations/fr.json new file mode 100644 index 00000000000..aa84ec33d8c --- /dev/null +++ b/homeassistant/components/litterrobot/translations/fr.json @@ -0,0 +1,20 @@ +{ + "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": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/it.json b/homeassistant/components/litterrobot/translations/it.json new file mode 100644 index 00000000000..843262aa318 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/nl.json b/homeassistant/components/litterrobot/translations/nl.json new file mode 100644 index 00000000000..50b4c3f2fe6 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/no.json b/homeassistant/components/litterrobot/translations/no.json new file mode 100644 index 00000000000..4ea7b2401c3 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/pl.json b/homeassistant/components/litterrobot/translations/pl.json new file mode 100644 index 00000000000..8a08a06c699 --- /dev/null +++ b/homeassistant/components/litterrobot/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", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/ru.json b/homeassistant/components/litterrobot/translations/ru.json new file mode 100644 index 00000000000..3f4677a050e --- /dev/null +++ b/homeassistant/components/litterrobot/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.", + "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", + "username": "\u041b\u043e\u0433\u0438\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/zh-Hant.json b/homeassistant/components/litterrobot/translations/zh-Hant.json new file mode 100644 index 00000000000..d232b491b68 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "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", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py new file mode 100644 index 00000000000..6ee92993869 --- /dev/null +++ b/homeassistant/components/litterrobot/vacuum.py @@ -0,0 +1,140 @@ +"""Support for Litter-Robot "Vacuum".""" +from pylitterbot import Robot + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + SUPPORT_SEND_COMMAND, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STATUS, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + VacuumEntity, +) +from homeassistant.const import STATE_OFF +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .hub import LitterRobotEntity + +SUPPORT_LITTERROBOT = ( + SUPPORT_SEND_COMMAND + | SUPPORT_START + | SUPPORT_STATE + | SUPPORT_STATUS + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON +) +TYPE_LITTER_BOX = "Litter Box" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Litter-Robot cleaner using config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + for robot in hub.account.robots: + entities.append(LitterRobotCleaner(robot, TYPE_LITTER_BOX, hub)) + + if entities: + async_add_entities(entities, True) + + +class LitterRobotCleaner(LitterRobotEntity, VacuumEntity): + """Litter-Robot "Vacuum" Cleaner.""" + + @property + def supported_features(self): + """Flag cleaner robot features that are supported.""" + return SUPPORT_LITTERROBOT + + @property + def state(self): + """Return the state of the cleaner.""" + switcher = { + Robot.UnitStatus.CCP: STATE_CLEANING, + Robot.UnitStatus.EC: STATE_CLEANING, + Robot.UnitStatus.CCC: STATE_DOCKED, + Robot.UnitStatus.CST: STATE_DOCKED, + Robot.UnitStatus.DF1: STATE_DOCKED, + Robot.UnitStatus.DF2: STATE_DOCKED, + Robot.UnitStatus.RDY: STATE_DOCKED, + Robot.UnitStatus.OFF: STATE_OFF, + } + + return switcher.get(self.robot.unit_status, STATE_ERROR) + + @property + def error(self): + """Return the error associated with the current state, if any.""" + return self.robot.unit_status.value + + @property + def status(self): + """Return the status of the cleaner.""" + return f"{self.robot.unit_status.value}{' (Sleeping)' if self.robot.is_sleeping else ''}" + + async def async_turn_on(self, **kwargs): + """Turn the cleaner on, starting a clean cycle.""" + await self.perform_action_and_refresh(self.robot.set_power_status, True) + + async def async_turn_off(self, **kwargs): + """Turn the unit off, stopping any cleaning in progress as is.""" + await self.perform_action_and_refresh(self.robot.set_power_status, False) + + async def async_start(self): + """Start a clean cycle.""" + await self.perform_action_and_refresh(self.robot.start_cleaning) + + async def async_send_command(self, command, params=None, **kwargs): + """Send command. + + Available commands: + - reset_waste_drawer + * params: none + - set_sleep_mode + * params: + - enabled: bool + - sleep_time: str (optional) + + """ + if command == "reset_waste_drawer": + # Normally we need to request a refresh of data after a command is sent. + # However, the API for resetting the waste drawer returns a refreshed + # data set for the robot. Thus, we only need to tell hass to update the + # state of devices associated with this robot. + await self.robot.reset_waste_drawer() + self.hub.coordinator.async_set_updated_data(True) + elif command == "set_sleep_mode": + await self.perform_action_and_refresh( + self.robot.set_sleep_mode, + params.get("enabled"), + self.parse_time_at_default_timezone(params.get("sleep_time")), + ) + else: + raise NotImplementedError() + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + [sleep_mode_start_time, sleep_mode_end_time] = [None, None] + + if self.robot.sleep_mode_active: + sleep_mode_start_time = dt_util.as_local( + self.robot.sleep_mode_start_time + ).strftime("%H:%M:00") + sleep_mode_end_time = dt_util.as_local( + self.robot.sleep_mode_end_time + ).strftime("%H:%M:00") + + return { + "clean_cycle_wait_time_minutes": self.robot.clean_cycle_wait_time_minutes, + "is_sleeping": self.robot.is_sleeping, + "sleep_mode_start_time": sleep_mode_start_time, + "sleep_mode_end_time": sleep_mode_end_time, + "power_status": self.robot.power_status, + "unit_status_code": self.robot.unit_status.name, + "last_seen": self.robot.last_seen, + } diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 1d06efeb708..b0b84677183 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -10,16 +10,10 @@ from homeassistant.components.camera import ( PLATFORM_SCHEMA, Camera, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH, CONF_NAME from homeassistant.helpers import config_validation as cv -from .const import ( - CONF_FILE_PATH, - DATA_LOCAL_FILE, - DEFAULT_NAME, - DOMAIN, - SERVICE_UPDATE_FILE_PATH, -) +from .const import DATA_LOCAL_FILE, DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/local_file/const.py b/homeassistant/components/local_file/const.py index 5225a70daed..3ea98f89c0e 100644 --- a/homeassistant/components/local_file/const.py +++ b/homeassistant/components/local_file/const.py @@ -1,6 +1,5 @@ """Constants for the Local File Camera component.""" DOMAIN = "local_file" SERVICE_UPDATE_FILE_PATH = "update_file_path" -CONF_FILE_PATH = "file_path" DATA_LOCAL_FILE = "local_file_cameras" DEFAULT_NAME = "Local File" diff --git a/homeassistant/components/local_ip/translations/fr.json b/homeassistant/components/local_ip/translations/fr.json index c1933032ed0..1c5a8fc9634 100644 --- a/homeassistant/components/local_ip/translations/fr.json +++ b/homeassistant/components/local_ip/translations/fr.json @@ -8,6 +8,7 @@ "data": { "name": "Nom du capteur" }, + "description": "Voulez-vous commencer la configuration ?", "title": "Adresse IP locale" } } diff --git a/homeassistant/components/local_ip/translations/ko.json b/homeassistant/components/local_ip/translations/ko.json index 050229dbf08..3b543f87f79 100644 --- a/homeassistant/components/local_ip/translations/ko.json +++ b/homeassistant/components/local_ip/translations/ko.json @@ -1,13 +1,14 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 \ub85c\uceec IP \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "user": { "data": { "name": "\uc13c\uc11c \uc774\ub984" }, + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "\ub85c\uceec IP \uc8fc\uc18c" } } diff --git a/homeassistant/components/local_ip/translations/nl.json b/homeassistant/components/local_ip/translations/nl.json index ba75a9b2a4d..57547adedd8 100644 --- a/homeassistant/components/local_ip/translations/nl.json +++ b/homeassistant/components/local_ip/translations/nl.json @@ -8,6 +8,7 @@ "data": { "name": "Sensor Naam" }, + "description": "Wil je beginnen met instellen?", "title": "Lokaal IP-adres" } } diff --git a/homeassistant/components/locative/translations/ko.json b/homeassistant/components/locative/translations/ko.json index eb10a8ca167..5930e7edf1b 100644 --- a/homeassistant/components/locative/translations/ko.json +++ b/homeassistant/components/locative/translations/ko.json @@ -1,11 +1,15 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Locative \uc571\uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "step": { "user": { - "description": "Locative \uc6f9 \ud6c5\uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Locative \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" } } diff --git a/homeassistant/components/locative/translations/nl.json b/homeassistant/components/locative/translations/nl.json index e02378432ab..16cbbc77277 100644 --- a/homeassistant/components/locative/translations/nl.json +++ b/homeassistant/components/locative/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om locaties naar Home Assistant te sturen, moet u de Webhook-functie instellen in de Locative app. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Methode: POST \n\n Zie [de documentatie]({docs_url}) voor meer informatie." diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 091811446b5..05d5041ca65 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -74,16 +74,13 @@ async def async_attach_trigger( config = TRIGGER_SCHEMA(config) if config[CONF_TYPE] == "locked": - from_state = STATE_UNLOCKED to_state = STATE_LOCKED else: - from_state = STATE_LOCKED to_state = STATE_UNLOCKED state_config = { CONF_PLATFORM: "state", CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state_trigger.CONF_FROM: from_state, state_trigger.CONF_TO: to_state, } state_config = state_trigger.TRIGGER_SCHEMA(state_config) diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index d1456f1e68e..f5f6077ddc1 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -21,26 +21,31 @@ get_usercode: example: 1 lock: + name: Lock description: Lock all or specified locks. + target: fields: - entity_id: - description: Name of lock to lock. - example: "lock.front_door" code: + name: Code description: An optional code to lock the lock with. example: 1234 + selector: + text: open: + name: Open description: Open all or specified locks. + target: fields: - entity_id: - description: Name of lock to open. - example: "lock.front_door" code: + name: Code description: An optional code to open the lock with. example: 1234 + selector: + text: set_usercode: + name: Set usercode description: Set a usercode to lock. fields: node_id: @@ -54,11 +59,13 @@ set_usercode: example: 1234 unlock: + name: Unlock description: Unlock all or specified locks. + target: fields: - entity_id: - description: Name of lock to unlock. - example: "lock.front_door" code: + name: Code description: An optional code to unlock the lock with. example: 1234 + selector: + text: diff --git a/homeassistant/components/lock/significant_change.py b/homeassistant/components/lock/significant_change.py new file mode 100644 index 00000000000..59a3b1a95c5 --- /dev/null +++ b/homeassistant/components/lock/significant_change.py @@ -0,0 +1,20 @@ +"""Helper to test significant Lock state changes.""" +from typing import Any, Optional + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> Optional[bool]: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + return False diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index e2d8a22c251..3b77e6e6409 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -54,8 +54,6 @@ ICON_JSON_EXTRACT = re.compile('"icon": "([^"]+)"') ATTR_MESSAGE = "message" -CONF_DOMAINS = "domains" -CONF_ENTITIES = "entities" CONTINUOUS_DOMAINS = ["proximity", "sensor"] DOMAIN = "logbook" @@ -417,7 +415,6 @@ def _get_events( entity_matches_only=False, ): """Get events for a period of time.""" - entity_attr_cache = EntityAttributeCache(hass) context_lookup = {None: None} diff --git a/homeassistant/components/logbook/services.yaml b/homeassistant/components/logbook/services.yaml index fb1736d7784..252b0b6a39b 100644 --- a/homeassistant/components/logbook/services.yaml +++ b/homeassistant/components/logbook/services.yaml @@ -1,15 +1,29 @@ log: - description: Create a custom entry in your logbook. + description: Create a custom entry in your logbook fields: name: + name: Name description: Custom name for an entity, can be referenced with entity_id + required: true example: "Kitchen" + selector: + text: message: + name: Message description: Message of the custom logbook entry + required: true example: "is being used" + selector: + text: entity_id: + name: Entity ID description: Entity to reference in custom logbook entry [Optional] example: "light.kitchen" + selector: + entity: domain: + name: Domain description: Icon of domain to display in custom logbook entry [Optional] example: "light" + selector: + text: diff --git a/homeassistant/components/logger/services.yaml b/homeassistant/components/logger/services.yaml index 514aac4c71c..39c0bcfdfe1 100644 --- a/homeassistant/components/logger/services.yaml +++ b/homeassistant/components/logger/services.yaml @@ -1,22 +1,43 @@ set_default_level: - description: Set the default log level for components. + name: Set default level + description: Set the default log level for integrations. fields: level: - description: "Default severity level. Possible values are debug, info, warn, warning, error, fatal, critical" + name: Level + description: Default severity level for all integrations. example: debug + selector: + select: + options: + - debug + - info + - warning + - error + - fatal + - critical set_level: - description: Set log level for components. + name: Set level + description: Set log level for integrations. fields: homeassistant.core: - description: "Example on how to change the logging level for a Home Assistant core components. Possible values are debug, info, warn, warning, error, fatal, critical" + description: + "Example on how to change the logging level for a Home Assistant Core + integrations. Possible values are debug, info, warn, warning, error, + fatal, critical." example: debug homeassistant.components.mqtt: - description: "Example on how to change the logging level for an Integration. Possible values are debug, info, warn, warning, error, fatal, critical" + description: + "Example on how to change the logging level for an Integration. Possible + values are debug, info, warn, warning, error, fatal, critical." example: warning custom_components.my_integration: - description: "Example on how to change the logging level for a Custom Integration. Possible values are debug, info, warn, warning, error, fatal, critical" + description: + "Example on how to change the logging level for a Custom Integration. + Possible values are debug, info, warn, warning, error, fatal, critical." example: debug aiohttp: - description: "Example on how to change the logging level for a Python module. Possible values are debug, info, warn, warning, error, fatal, critical" + description: + "Example on how to change the logging level for a Python module. + Possible values are debug, info, warn, warning, error, fatal, critical." example: error diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index d3551765079..056783ef6da 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -11,6 +11,7 @@ from homeassistant import config_entries from homeassistant.components.camera import ATTR_FILENAME, CAMERA_SERVICE_SCHEMA from homeassistant.const import ( ATTR_MODE, + CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_MONITORED_CONDITIONS, @@ -22,7 +23,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from . import config_flow from .const import ( - CONF_API_KEY, CONF_REDIRECT_URI, DATA_LOGI, DEFAULT_CACHEDB, @@ -117,7 +117,6 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up Logi Circle from a config entry.""" - logi_circle = LogiCircle( client_id=entry.data[CONF_CLIENT_ID], client_secret=entry.data[CONF_CLIENT_SECRET], diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 279c3052010..00fd0edc437 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( + CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_SENSORS, @@ -17,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback -from .const import CONF_API_KEY, CONF_REDIRECT_URI, DEFAULT_CACHEDB, DOMAIN +from .const import CONF_REDIRECT_URI, DEFAULT_CACHEDB, DOMAIN _TIMEOUT = 15 # seconds @@ -120,7 +121,6 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow): def _get_authorization_url(self): """Create temporary Circle session and generate authorization url.""" - flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] client_id = flow[CONF_CLIENT_ID] client_secret = flow[CONF_CLIENT_SECRET] @@ -147,7 +147,6 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow): async def _async_create_session(self, code): """Create Logi Circle session and entries.""" - flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] client_id = flow[CONF_CLIENT_ID] client_secret = flow[CONF_CLIENT_SECRET] diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py index fb22338b2c7..92967d2eb84 100644 --- a/homeassistant/components/logi_circle/const.py +++ b/homeassistant/components/logi_circle/const.py @@ -4,7 +4,6 @@ from homeassistant.const import PERCENTAGE DOMAIN = "logi_circle" DATA_LOGI = DOMAIN -CONF_API_KEY = "api_key" CONF_REDIRECT_URI = "redirect_uri" DEFAULT_CACHEDB = ".logi_cache.pickle" diff --git a/homeassistant/components/logi_circle/translations/ko.json b/homeassistant/components/logi_circle/translations/ko.json index 3fe8ce4824e..2300bbb27c6 100644 --- a/homeassistant/components/logi_circle/translations/ko.json +++ b/homeassistant/components/logi_circle/translations/ko.json @@ -1,11 +1,15 @@ { "config": { "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "external_error": "\ub2e4\ub978 \uad6c\uc131 \ub2e8\uacc4\uc5d0\uc11c \uc608\uc678\uc0ac\ud56d\uc774 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", - "external_setup": "Logi Circle \uc774 \ub2e4\ub978 \uad6c\uc131 \ub2e8\uacc4\uc5d0\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "external_setup": "Logi Circle \uc774 \ub2e4\ub978 \uad6c\uc131 \ub2e8\uacc4\uc5d0\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." }, "error": { - "follow_link": "\ud655\uc778\uc744 \ud074\ub9ad\ud558\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694" + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "follow_link": "\ud655\uc778\uc744 \ud074\ub9ad\ud558\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "auth": { diff --git a/homeassistant/components/logi_circle/translations/nl.json b/homeassistant/components/logi_circle/translations/nl.json index b521af1f969..36970feb48b 100644 --- a/homeassistant/components/logi_circle/translations/nl.json +++ b/homeassistant/components/logi_circle/translations/nl.json @@ -3,9 +3,11 @@ "abort": { "already_configured": "Account is al geconfigureerd", "external_error": "Uitzondering opgetreden uit een andere stroom.", - "external_setup": "Logi Circle is met succes geconfigureerd vanuit een andere stroom." + "external_setup": "Logi Circle is met succes geconfigureerd vanuit een andere stroom.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen." }, "error": { + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "follow_link": "Volg de link en authenticeer voordat u op Verzenden drukt.", "invalid_auth": "Ongeldige authenticatie" }, diff --git a/homeassistant/components/logi_circle/translations/ru.json b/homeassistant/components/logi_circle/translations/ru.json index 2a7ccc4f374..8da20b60c39 100644 --- a/homeassistant/components/logi_circle/translations/ru.json +++ b/homeassistant/components/logi_circle/translations/ru.json @@ -9,7 +9,7 @@ "error": { "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.", "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "auth": { diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 99b00a92289..e673b2a470b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.config import async_hass_config_yaml, async_process_component_config -from homeassistant.const import CONF_FILENAME +from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv @@ -16,9 +16,7 @@ from homeassistant.loader import async_get_integration from . import dashboard, resources, websocket from .const import ( CONF_ICON, - CONF_MODE, CONF_REQUIRE_ADMIN, - CONF_RESOURCES, CONF_SHOW_IN_SIDEBAR, CONF_TITLE, CONF_URL_PATH, diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index e93649de451..6952a80a214 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -3,7 +3,7 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_ICON, CONF_TYPE, CONF_URL +from homeassistant.const import CONF_ICON, CONF_MODE, CONF_TYPE, CONF_URL from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -13,13 +13,11 @@ EVENT_LOVELACE_UPDATED = "lovelace_updated" DEFAULT_ICON = "hass:view-dashboard" -CONF_MODE = "mode" MODE_YAML = "yaml" MODE_STORAGE = "storage" MODE_AUTO = "auto-gen" LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" -CONF_RESOURCES = "resources" CONF_URL_PATH = "url_path" CONF_RESOURCE_TYPE_WS = "res_type" diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 78a23540ed4..0a3e36892d5 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -5,14 +5,13 @@ import uuid import voluptuous as vol -from homeassistant.const import CONF_TYPE +from homeassistant.const import CONF_RESOURCES, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage from .const import ( CONF_RESOURCE_TYPE_WS, - CONF_RESOURCES, DOMAIN, RESOURCE_CREATE_FIELDS, RESOURCE_SCHEMA, diff --git a/homeassistant/components/lovelace/services.yaml b/homeassistant/components/lovelace/services.yaml index 1147f287e59..b324b551e94 100644 --- a/homeassistant/components/lovelace/services.yaml +++ b/homeassistant/components/lovelace/services.yaml @@ -1,4 +1,4 @@ # Describes the format for available lovelace services reload_resources: - description: Reload Lovelace resources from yaml configuration. + description: Reload Lovelace resources from YAML configuration diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index a148427c9bd..2f4cfc6af76 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -2,9 +2,10 @@ import asyncio from homeassistant.components import system_health +from homeassistant.const import CONF_MODE from homeassistant.core import HomeAssistant, callback -from .const import CONF_MODE, DOMAIN, MODE_AUTO, MODE_STORAGE, MODE_YAML +from .const import DOMAIN, MODE_AUTO, MODE_STORAGE, MODE_YAML @callback diff --git a/homeassistant/components/luftdaten/translations/ko.json b/homeassistant/components/luftdaten/translations/ko.json index eb69dfb64a2..fbb5a26e7ee 100644 --- a/homeassistant/components/luftdaten/translations/ko.json +++ b/homeassistant/components/luftdaten/translations/ko.json @@ -1,6 +1,8 @@ { "config": { "error": { + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_sensor": "\uc13c\uc11c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac70\ub098 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/luftdaten/translations/nl.json b/homeassistant/components/luftdaten/translations/nl.json index b3bdf2442b8..dc913232e8c 100644 --- a/homeassistant/components/luftdaten/translations/nl.json +++ b/homeassistant/components/luftdaten/translations/nl.json @@ -2,6 +2,7 @@ "config": { "error": { "already_configured": "Service is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", "invalid_sensor": "Sensor niet beschikbaar of ongeldig" }, "step": { diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 2dbeb51da58..fdd47d9005d 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -2,6 +2,6 @@ "domain": "lutron", "name": "Lutron", "documentation": "https://www.home-assistant.io/integrations/lutron", - "requirements": ["pylutron==0.2.5"], + "requirements": ["pylutron==0.2.7"], "codeowners": ["@JonGilmore"] } diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 73eb0b83fa6..56cc7a78c96 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -158,7 +158,11 @@ async def async_setup_lip(hass, config_entry, lip_devices): try: await lip.async_connect(host) except asyncio.TimeoutError: - _LOGGER.error("Failed to connect to via LIP at %s:23", host) + _LOGGER.warning( + "Failed to connect to via LIP at %s:23, Pico and Shade remotes will not be available; " + "Enable Telnet Support in the Lutron app under Settings >> Advanced >> Integration", + host, + ) return _LOGGER.debug("Connected to Lutron Caseta bridge via LIP at %s:23", host) @@ -225,6 +229,7 @@ async def _async_register_button_devices( dr_device = device_registry.async_get_or_create( name=device["leap_name"], + suggested_area=device["leap_name"].split("_")[0], manufacturer=MANUFACTURER, config_entry_id=config_entry_id, identifiers={(DOMAIN, device["serial"])}, @@ -340,6 +345,7 @@ class LutronCasetaDevice(Entity): return { "identifiers": {(DOMAIN, self.serial)}, "name": self.name, + "suggested_area": self._device["name"].split("_")[0], "manufacturer": MANUFACTURER, "model": f"{self._device['model']} ({self._device['type']})", "via_device": (DOMAIN, self._bridge_device["serial"]), diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index ab9865f999a..6cd30a78f0c 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -77,7 +77,6 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured({CONF_HOST: host}) self.data[CONF_HOST] = host - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_NAME: self.bridge_id, CONF_HOST: host, @@ -201,8 +200,6 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import_failed(self, user_input=None): """Make failed import surfaced to user.""" - - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {CONF_NAME: self.data[CONF_HOST]} if user_input is None: diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index f8f9ee668c2..fcc647f00ba 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -19,7 +19,7 @@ LUTRON_CASETA_BUTTON_EVENT = "lutron_caseta_button_event" BRIDGE_DEVICE_ID = "1" -MANUFACTURER = "Lutron" +MANUFACTURER = "Lutron Electronics Co., Inc" ATTR_SERIAL = "serial" ATTR_TYPE = "type" diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 402db7286af..86ee5e46b51 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -1,5 +1,4 @@ """Provides device triggers for lutron caseta.""" -import logging from typing import List import voluptuous as vol @@ -32,9 +31,6 @@ from .const import ( LUTRON_CASETA_BUTTON_EVENT, ) -_LOGGER = logging.getLogger(__name__) - - SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE] LUTRON_BUTTON_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( @@ -265,17 +261,15 @@ async def async_attach_trigger( schema = DEVICE_TYPE_SCHEMA_MAP.get(device["type"]) valid_buttons = DEVICE_TYPE_SUBTYPE_MAP.get(device["type"]) config = schema(config) - event_config = event_trigger.TRIGGER_SCHEMA( - { - event_trigger.CONF_PLATFORM: CONF_EVENT, - event_trigger.CONF_EVENT_TYPE: LUTRON_CASETA_BUTTON_EVENT, - event_trigger.CONF_EVENT_DATA: { - ATTR_SERIAL: device["serial"], - ATTR_BUTTON_NUMBER: valid_buttons[config[CONF_SUBTYPE]], - ATTR_ACTION: config[CONF_TYPE], - }, - } - ) + event_config = { + event_trigger.CONF_PLATFORM: CONF_EVENT, + event_trigger.CONF_EVENT_TYPE: LUTRON_CASETA_BUTTON_EVENT, + event_trigger.CONF_EVENT_DATA: { + ATTR_SERIAL: device["serial"], + ATTR_BUTTON_NUMBER: valid_buttons[config[CONF_SUBTYPE]], + ATTR_ACTION: config[CONF_TYPE], + }, + } event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 045cd35cd17..edda379aedc 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -3,14 +3,10 @@ import logging from pylutron_caseta import FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_OFF -from homeassistant.components.fan import ( - DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, +from homeassistant.components.fan import DOMAIN, SUPPORT_SET_SPEED, FanEntity +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, ) from . import LutronCasetaDevice @@ -18,23 +14,8 @@ from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN _LOGGER = logging.getLogger(__name__) -VALUE_TO_SPEED = { - None: SPEED_OFF, - FAN_OFF: SPEED_OFF, - FAN_LOW: SPEED_LOW, - FAN_MEDIUM: SPEED_MEDIUM, - FAN_MEDIUM_HIGH: SPEED_MEDIUM, - FAN_HIGH: SPEED_HIGH, -} - -SPEED_TO_VALUE = { - SPEED_OFF: FAN_OFF, - SPEED_LOW: FAN_LOW, - SPEED_MEDIUM: FAN_MEDIUM, - SPEED_HIGH: FAN_HIGH, -} - -FAN_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] +DEFAULT_ON_PERCENTAGE = 50 +ORDERED_NAMED_FAN_SPEEDS = [FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH] async def async_setup_entry(hass, config_entry, async_add_entities): @@ -61,42 +42,58 @@ class LutronCasetaFan(LutronCasetaDevice, FanEntity): """Representation of a Lutron Caseta fan. Including Fan Speed.""" @property - def speed(self) -> str: - """Return the current speed.""" - return VALUE_TO_SPEED[self._device["fan_speed"]] + def percentage(self) -> str: + """Return the current speed percentage.""" + if self._device["fan_speed"] is None: + return None + if self._device["fan_speed"] == FAN_OFF: + return 0 + return ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, self._device["fan_speed"] + ) @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return FAN_SPEEDS + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) @property def supported_features(self) -> int: """Flag supported features. Speed Only.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs): + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ): """Turn the fan on.""" - if speed is None: - speed = SPEED_MEDIUM - await self.async_set_speed(speed) + if percentage is None: + percentage = DEFAULT_ON_PERCENTAGE + + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs): """Turn the fan off.""" - await self.async_set_speed(SPEED_OFF) + await self.async_set_percentage(0) - async def async_set_speed(self, speed: str) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan.""" - await self._smartbridge.set_fan(self.device_id, SPEED_TO_VALUE[speed]) + if percentage == 0: + named_speed = FAN_OFF + else: + named_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + + await self._smartbridge.set_fan(self.device_id, named_speed) @property def is_on(self): """Return true if device is on.""" - return VALUE_TO_SPEED[self._device["fan_speed"]] in [ - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - ] + return self.percentage and self.percentage > 0 async def async_update(self): """Update when forcing a refresh of the device.""" diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index bdaec22e776..604a3c24ab2 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -8,7 +8,7 @@ }, "user": { "title": "Automaticlly connect to the bridge", - "description": "Enter the ip address of the device.", + "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" } diff --git a/homeassistant/components/lutron_caseta/translations/de.json b/homeassistant/components/lutron_caseta/translations/de.json index 13f8c6bd800..b6aacf2d0ef 100644 --- a/homeassistant/components/lutron_caseta/translations/de.json +++ b/homeassistant/components/lutron_caseta/translations/de.json @@ -6,6 +6,13 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/en.json b/homeassistant/components/lutron_caseta/translations/en.json index 8ea0672a3f3..96c00d6cb42 100644 --- a/homeassistant/components/lutron_caseta/translations/en.json +++ b/homeassistant/components/lutron_caseta/translations/en.json @@ -22,7 +22,7 @@ "data": { "host": "Host" }, - "description": "Enter the ip address of the device.", + "description": "Enter the IP address of the device.", "title": "Automaticlly connect to the bridge" } } diff --git a/homeassistant/components/lutron_caseta/translations/es.json b/homeassistant/components/lutron_caseta/translations/es.json index 37b1a0d9072..9dbedba1457 100644 --- a/homeassistant/components/lutron_caseta/translations/es.json +++ b/homeassistant/components/lutron_caseta/translations/es.json @@ -29,6 +29,27 @@ }, "device_automation": { "trigger_subtype": { + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close_1": "Cerrar 1", + "close_2": "Cerrar 2", + "close_3": "Cerrar 3", + "close_4": "Cerrar 4", + "close_all": "Cerrar todo", + "group_1_button_1": "Primer bot\u00f3n de primer grupo", + "group_1_button_2": "Segundo bot\u00f3n del primer grupo", + "group_2_button_1": "Primer bot\u00f3n del segundo grupo", + "group_2_button_2": "Segundo bot\u00f3n del segundo grupo", + "lower": "Inferior", + "lower_1": "Inferior 1", + "lower_2": "Inferior 2", + "lower_3": "Inferior 3", + "lower_4": "Inferior 4", + "lower_all": "Bajar todo", + "off": "Apagado", + "on": "Encendido", "open_1": "Abrir 1", "open_2": "Abrir 2", "open_3": "Abrir 3", @@ -46,6 +67,10 @@ "stop_3": "Detener 3", "stop_4": "Detener 4", "stop_all": "Detener todo" + }, + "trigger_type": { + "press": "\"{subtipo}\" presionado", + "release": "\"{subtipo}\" liberado" } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/fr.json b/homeassistant/components/lutron_caseta/translations/fr.json index 0674172e975..ff561548b44 100644 --- a/homeassistant/components/lutron_caseta/translations/fr.json +++ b/homeassistant/components/lutron_caseta/translations/fr.json @@ -2,16 +2,75 @@ "config": { "abort": { "already_configured": "Pont Cas\u00e9ta d\u00e9j\u00e0 configur\u00e9.", - "cannot_connect": "Installation annul\u00e9e du pont Cas\u00e9ta en raison d'un \u00e9chec de connexion." + "cannot_connect": "Installation annul\u00e9e du pont Cas\u00e9ta en raison d'un \u00e9chec de connexion.", + "not_lutron_device": "L'appareil d\u00e9couvert n'est pas un appareil Lutron" }, "error": { "cannot_connect": "\u00c9chec de la connexion \u00e0 la passerelle Cas\u00e9ta; v\u00e9rifiez la configuration de votre h\u00f4te et de votre certificat." }, + "flow_title": "Lutron Cas\u00e9ta {name} ( {host} )", "step": { "import_failed": { "description": "Impossible de configurer la passerelle (h\u00f4te: {host} ) import\u00e9 \u00e0 partir de configuration.yaml.", "title": "\u00c9chec de l'importation de la configuration de la passerelle Cas\u00e9ta." + }, + "link": { + "description": "Pour jumeler avec {name} ( {host} ), apr\u00e8s avoir soumis ce formulaire, appuyez sur le bouton noir \u00e0 l'arri\u00e8re du pont.", + "title": "Paire avec le pont" + }, + "user": { + "data": { + "host": "Hote" + }, + "description": "Saisissez l'adresse IP de l'appareil.", + "title": "Se connecter automatiquement au pont" } } + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Premier bouton", + "button_2": "Deuxi\u00e8me bouton", + "button_3": "Troisi\u00e8me bouton", + "button_4": "Quatri\u00e8me bouton", + "close_1": "Fermer 1", + "close_2": "Fermer 2", + "close_3": "Fermer 3", + "close_4": "Fermer 4", + "close_all": "Ferme tout", + "group_1_button_1": "Premier bouton du premier groupe", + "group_1_button_2": "Premier groupe deuxi\u00e8me bouton", + "group_2_button_1": "Premier bouton du deuxi\u00e8me groupe", + "group_2_button_2": "Deuxi\u00e8me bouton du deuxi\u00e8me groupe", + "lower": "Bas", + "lower_1": "Bas 1", + "lower_2": "Bas 2", + "lower_3": "Bas 3", + "lower_4": "Bas 4", + "lower_all": "Tout baisser", + "off": "Eteint", + "on": "Allumer", + "open_1": "Ouvrir 1", + "open_2": "Ouvrir 2", + "open_3": "Ouvrir 3", + "open_4": "Ouvrir 4", + "open_all": "Ouvre tout", + "raise": "Haut", + "raise_1": "Haut 1", + "raise_2": "Haut 2", + "raise_3": "Haut 3", + "raise_4": "Haut 4", + "raise_all": "Lever tout", + "stop": "Stop (favori)", + "stop_1": "Arr\u00eat 1", + "stop_2": "Arr\u00eat 2", + "stop_3": "Arr\u00eat 3", + "stop_4": "Arr\u00eat 4", + "stop_all": "Arr\u00eate tout" + }, + "trigger_type": { + "press": "\" {subtype} \" appuy\u00e9", + "release": "\" {subtype} \" publi\u00e9" + } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/ko.json b/homeassistant/components/lutron_caseta/translations/ko.json index 8c5caec998e..af7fed5829c 100644 --- a/homeassistant/components/lutron_caseta/translations/ko.json +++ b/homeassistant/components/lutron_caseta/translations/ko.json @@ -1,16 +1,21 @@ { "config": { "abort": { - "already_configured": "Cas\u00e9ta \ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "cannot_connect": "Cas\u00e9ta \ube0c\ub9ac\uc9c0 \uc5f0\uacb0 \uc2e4\ud328\ub85c \uc124\uc815\uc774 \ucde8\uc18c\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "Cas\u00e9ta \ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8 \ubc0f \uc778\uc99d\uc11c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "import_failed": { "description": "configuration.yaml \uc5d0\uc11c \uac00\uc838\uc628 \ube0c\ub9ac\uc9c0 (\ud638\uc2a4\ud2b8:{host}) \ub97c \uc124\uc815\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "title": "Cas\u00e9ta \ube0c\ub9ac\uc9c0 \uad6c\uc131\uc744 \uac00\uc838\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + } } } } diff --git a/homeassistant/components/lutron_caseta/translations/nl.json b/homeassistant/components/lutron_caseta/translations/nl.json index 8e48dea075d..17e6fc47fd8 100644 --- a/homeassistant/components/lutron_caseta/translations/nl.json +++ b/homeassistant/components/lutron_caseta/translations/nl.json @@ -7,10 +7,49 @@ "error": { "cannot_connect": "Kon niet verbinden" }, + "flow_title": "Lutron Cas\u00e9ta {name} ({host})", "step": { "import_failed": { "description": "Kan bridge (host: {host} ) niet instellen, ge\u00efmporteerd uit configuration.yaml." + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Voer het IP-adres van het apparaat in." } } + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Eerste knop", + "button_2": "Tweede knop", + "button_3": "Derde knop", + "button_4": "Vierde knop", + "close_1": "Sluit 1", + "close_2": "Sluit 2", + "close_3": "Sluit 3", + "close_4": "Sluit 4", + "close_all": "Sluit alles", + "group_1_button_1": "Eerste Groep eerste knop", + "group_1_button_2": "Eerste Groep tweede knop", + "group_2_button_1": "Tweede Groep eerste knop", + "group_2_button_2": "Tweede Groep tweede knop", + "off": "Uit", + "on": "Aan", + "open_1": "Open 1", + "open_2": "Open 2", + "open_3": "Open 3", + "open_4": "Open 4", + "stop": "Stop (favoriet)", + "stop_1": "Stop 1", + "stop_2": "Stop 2", + "stop_3": "Stop 3", + "stop_4": "Stop 4" + }, + "trigger_type": { + "press": "\" {subtype} \" ingedrukt", + "release": "\"{subtype}\" losgelaten" + } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/no.json b/homeassistant/components/lutron_caseta/translations/no.json index 477370100af..b985c87caf0 100644 --- a/homeassistant/components/lutron_caseta/translations/no.json +++ b/homeassistant/components/lutron_caseta/translations/no.json @@ -22,7 +22,7 @@ "data": { "host": "Vert" }, - "description": "Skriv inn ip-adressen til enheten.", + "description": "Skriv inn IP-adressen til enheten.", "title": "Koble automatisk til broen" } } diff --git a/homeassistant/components/lutron_caseta/translations/ru.json b/homeassistant/components/lutron_caseta/translations/ru.json index edda7af8e9a..f54057f464e 100644 --- a/homeassistant/components/lutron_caseta/translations/ru.json +++ b/homeassistant/components/lutron_caseta/translations/ru.json @@ -42,6 +42,25 @@ "group_1_button_2": "\u041f\u0435\u0440\u0432\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430 \u0432\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "group_2_button_1": "\u0412\u0442\u043e\u0440\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430 \u043f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "group_2_button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430 \u0432\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "lower": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c", + "lower_1": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c 1", + "lower_2": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c 2", + "lower_3": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c 3", + "lower_4": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c 4", + "lower_all": "\u041e\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0432\u0441\u0435", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "open_1": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c 1", + "open_2": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c 2", + "open_3": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c 3", + "open_4": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c 4", + "open_all": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0432\u0441\u0435", + "raise": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c", + "raise_1": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c 1", + "raise_2": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c 2", + "raise_3": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c 3", + "raise_4": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c 4", + "raise_all": "\u041f\u043e\u0434\u043d\u044f\u0442\u044c \u0432\u0441\u0435", "stop": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c (\u043b\u044e\u0431\u0438\u043c\u0430\u044f)", "stop_1": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c 1", "stop_2": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c 2", diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py new file mode 100644 index 00000000000..12990d66ba9 --- /dev/null +++ b/homeassistant/components/lyric/__init__.py @@ -0,0 +1,200 @@ +"""The Honeywell Lyric integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict, Optional + +from aiolyric import Lyric +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +import async_timeout +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, +) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .api import ConfigEntryLyricClient, LyricLocalOAuth2Implementation +from .config_flow import OAuth2FlowHandler +from .const import DOMAIN, LYRIC_EXCEPTIONS, OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate", "sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Honeywell Lyric component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + hass.data[DOMAIN][CONF_CLIENT_ID] = config[DOMAIN][CONF_CLIENT_ID] + + OAuth2FlowHandler.async_register_implementation( + hass, + LyricLocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ), + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Honeywell Lyric from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = aiohttp_client.async_get_clientsession(hass) + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + client = ConfigEntryLyricClient(session, oauth_session) + + client_id = hass.data[DOMAIN][CONF_CLIENT_ID] + lyric = Lyric(client, client_id) + + async def async_update_data() -> Lyric: + """Fetch data from Lyric.""" + try: + async with async_timeout.timeout(60): + await lyric.get_locations() + return lyric + except LYRIC_EXCEPTIONS as exception: + raise UpdateFailed(exception) from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="lyric_coordinator", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=120), + ) + + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class LyricEntity(CoordinatorEntity): + """Defines a base Honeywell Lyric entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + key: str, + name: str, + icon: Optional[str], + ) -> None: + """Initialize the Honeywell Lyric entity.""" + super().__init__(coordinator) + self._key = key + self._name = name + self._icon = icon + self._location = location + self._mac_id = device.macID + self._device_name = device.name + self._device_model = device.deviceModel + self._update_thermostat = coordinator.data.update_thermostat + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @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 location(self) -> LyricLocation: + """Get the Lyric Location.""" + return self.coordinator.data.locations_dict[self._location.locationID] + + @property + def device(self) -> LyricDevice: + """Get the Lyric Device.""" + return self.location.devices_dict[self._mac_id] + + +class LyricDeviceEntity(LyricEntity): + """Defines a Honeywell Lyric device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Honeywell Lyric instance.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, + "manufacturer": "Honeywell", + "model": self._device_model, + "name": self._device_name, + } diff --git a/homeassistant/components/lyric/api.py b/homeassistant/components/lyric/api.py new file mode 100644 index 00000000000..a77c6365baf --- /dev/null +++ b/homeassistant/components/lyric/api.py @@ -0,0 +1,55 @@ +"""API for Honeywell Lyric bound to Home Assistant OAuth.""" +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientSession +from aiolyric.client import LyricClient + +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +class ConfigEntryLyricClient(LyricClient): + """Provide Honeywell Lyric authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize Honeywell Lyric auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] + + +class LyricLocalOAuth2Implementation( + config_entry_oauth2_flow.LocalOAuth2Implementation +): + """Lyric Local OAuth2 implementation.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + data["client_id"] = self.client_id + + if self.client_secret is not None: + data["client_secret"] = self.client_secret + + headers = { + "Authorization": BasicAuth(self.client_id, self.client_secret).encode(), + "Content-Type": "application/x-www-form-urlencoded", + } + + resp = await session.post(self.token_url, headers=headers, data=data) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py new file mode 100644 index 00000000000..41e8fa90b67 --- /dev/null +++ b/homeassistant/components/lyric/climate.py @@ -0,0 +1,277 @@ +"""Support for Honeywell Lyric climate platform.""" +import logging +from time import gmtime, strftime, time +from typing import List, Optional + +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +import voluptuous as vol + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import LyricDeviceEntity +from .const import ( + DOMAIN, + LYRIC_EXCEPTIONS, + PRESET_HOLD_UNTIL, + PRESET_NO_HOLD, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + +LYRIC_HVAC_MODE_OFF = "Off" +LYRIC_HVAC_MODE_HEAT = "Heat" +LYRIC_HVAC_MODE_COOL = "Cool" +LYRIC_HVAC_MODE_HEAT_COOL = "Auto" + +LYRIC_HVAC_MODES = { + HVAC_MODE_OFF: LYRIC_HVAC_MODE_OFF, + HVAC_MODE_HEAT: LYRIC_HVAC_MODE_HEAT, + HVAC_MODE_COOL: LYRIC_HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL: LYRIC_HVAC_MODE_HEAT_COOL, +} + +HVAC_MODES = { + LYRIC_HVAC_MODE_OFF: HVAC_MODE_OFF, + LYRIC_HVAC_MODE_HEAT: HVAC_MODE_HEAT, + LYRIC_HVAC_MODE_COOL: HVAC_MODE_COOL, + LYRIC_HVAC_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} + +SERVICE_HOLD_TIME = "set_hold_time" +ATTR_TIME_PERIOD = "time_period" + +SCHEMA_HOLD_TIME = { + vol.Required(ATTR_TIME_PERIOD, default="01:00:00"): vol.All( + cv.time_period, + cv.positive_timedelta, + lambda td: strftime("%H:%M:%S", gmtime(time() + td.total_seconds())), + ) +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Honeywell Lyric climate platform based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for location in coordinator.data.locations: + for device in location.devices: + entities.append( + LyricClimate( + coordinator, location, device, hass.config.units.temperature_unit + ) + ) + + async_add_entities(entities, True) + + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_HOLD_TIME, + SCHEMA_HOLD_TIME, + "async_set_hold_time", + ) + + +class LyricClimate(LyricDeviceEntity, ClimateEntity): + """Defines a Honeywell Lyric climate entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + temperature_unit: str, + ) -> None: + """Initialize Honeywell Lyric climate entity.""" + self._temperature_unit = temperature_unit + + # Setup supported hvac modes + self._hvac_modes = [HVAC_MODE_OFF] + + # Add supported lyric thermostat features + if LYRIC_HVAC_MODE_HEAT in device.allowedModes: + self._hvac_modes.append(HVAC_MODE_HEAT) + + if LYRIC_HVAC_MODE_COOL in device.allowedModes: + self._hvac_modes.append(HVAC_MODE_COOL) + + if ( + LYRIC_HVAC_MODE_HEAT in device.allowedModes + and LYRIC_HVAC_MODE_COOL in device.allowedModes + ): + self._hvac_modes.append(HVAC_MODE_HEAT_COOL) + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_thermostat", + device.name, + None, + ) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self.device.indoorTemperature + + @property + def hvac_mode(self) -> str: + """Return the hvac mode.""" + return HVAC_MODES[self.device.changeableValues.mode] + + @property + def hvac_modes(self) -> List[str]: + """List of available hvac modes.""" + return self._hvac_modes + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + device = self.device + if not device.hasDualSetpointStatus: + return device.changeableValues.heatSetpoint + return None + + @property + def target_temperature_low(self) -> Optional[float]: + """Return the upper bound temperature we try to reach.""" + device = self.device + if device.hasDualSetpointStatus: + return device.changeableValues.coolSetpoint + return None + + @property + def target_temperature_high(self) -> Optional[float]: + """Return the upper bound temperature we try to reach.""" + device = self.device + if device.hasDualSetpointStatus: + return device.changeableValues.heatSetpoint + return None + + @property + def preset_mode(self) -> Optional[str]: + """Return current preset mode.""" + return self.device.changeableValues.thermostatSetpointStatus + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return preset modes.""" + return [ + PRESET_NO_HOLD, + PRESET_HOLD_UNTIL, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, + ] + + @property + def min_temp(self) -> float: + """Identify min_temp in Lyric API or defaults if not available.""" + device = self.device + if LYRIC_HVAC_MODE_COOL in device.allowedModes: + return device.minCoolSetpoint + return device.minHeatSetpoint + + @property + def max_temp(self) -> float: + """Identify max_temp in Lyric API or defaults if not available.""" + device = self.device + if LYRIC_HVAC_MODE_HEAT in device.allowedModes: + return device.maxHeatSetpoint + return device.maxCoolSetpoint + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + + device = self.device + if device.hasDualSetpointStatus: + if target_temp_low is not None and target_temp_high is not None: + temp = (target_temp_low, target_temp_high) + else: + raise HomeAssistantError( + "Could not find target_temp_low and/or target_temp_high in arguments" + ) + else: + temp = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.debug("Set temperature: %s", temp) + try: + await self._update_thermostat(self.location, device, heatSetpoint=temp) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + _LOGGER.debug("Set hvac mode: %s", hvac_mode) + try: + await self._update_thermostat( + self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode] + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode.""" + _LOGGER.debug("Set preset mode: %s", preset_mode) + try: + await self._update_thermostat( + self.location, self.device, thermostatSetpointStatus=preset_mode + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_hold_time(self, time_period: str) -> None: + """Set the time to hold until.""" + _LOGGER.debug("set_hold_time: %s", time_period) + try: + await self._update_thermostat( + self.location, + self.device, + thermostatSetpointStatus=PRESET_HOLD_UNTIL, + nextPeriodTime=time_period, + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/config_flow.py b/homeassistant/components/lyric/config_flow.py new file mode 100644 index 00000000000..1370d5e67ea --- /dev/null +++ b/homeassistant/components/lyric/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for Honeywell Lyric.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Honeywell Lyric OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/lyric/const.py b/homeassistant/components/lyric/const.py new file mode 100644 index 00000000000..4f2f72b937b --- /dev/null +++ b/homeassistant/components/lyric/const.py @@ -0,0 +1,20 @@ +"""Constants for the Honeywell Lyric integration.""" +from aiohttp.client_exceptions import ClientResponseError +from aiolyric.exceptions import LyricAuthenticationException, LyricException + +DOMAIN = "lyric" + +OAUTH2_AUTHORIZE = "https://api.honeywell.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.honeywell.com/oauth2/token" + +PRESET_NO_HOLD = "NoHold" +PRESET_TEMPORARY_HOLD = "TemporaryHold" +PRESET_HOLD_UNTIL = "HoldUntil" +PRESET_PERMANENT_HOLD = "PermanentHold" +PRESET_VACATION_HOLD = "VacationHold" + +LYRIC_EXCEPTIONS = ( + LyricAuthenticationException, + LyricException, + ClientResponseError, +) diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json new file mode 100644 index 00000000000..460eb6e2a3d --- /dev/null +++ b/homeassistant/components/lyric/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "lyric", + "name": "Honeywell Lyric", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lyric", + "dependencies": ["http"], + "requirements": ["aiolyric==1.0.5"], + "codeowners": ["@timmo001"], + "quality_scale": "silver", + "dhcp": [ + { + "hostname": "lyric-*", + "macaddress": "48A2E6" + }, + { + "hostname": "lyric-*", + "macaddress": "B82CA0" + }, + { + "hostname": "lyric-*", + "macaddress": "00D02D" + } + ] +} diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py new file mode 100644 index 00000000000..c4950f119d9 --- /dev/null +++ b/homeassistant/components/lyric/sensor.py @@ -0,0 +1,251 @@ +"""Support for Honeywell Lyric sensor platform.""" +from datetime import datetime, timedelta + +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, +) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from . import LyricDeviceEntity +from .const import ( + DOMAIN, + PRESET_HOLD_UNTIL, + PRESET_NO_HOLD, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, +) + +LYRIC_SETPOINT_STATUS_NAMES = { + PRESET_NO_HOLD: "Following Schedule", + PRESET_PERMANENT_HOLD: "Held Permanently", + PRESET_TEMPORARY_HOLD: "Held Temporarily", + PRESET_VACATION_HOLD: "Holiday", +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Honeywell Lyric sensor platform based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for location in coordinator.data.locations: + for device in location.devices: + cls_list = [] + if device.indoorTemperature: + cls_list.append(LyricIndoorTemperatureSensor) + if device.outdoorTemperature: + cls_list.append(LyricOutdoorTemperatureSensor) + if device.displayedOutdoorHumidity: + cls_list.append(LyricOutdoorHumiditySensor) + if device.changeableValues: + if device.changeableValues.nextPeriodTime: + cls_list.append(LyricNextPeriodSensor) + if device.changeableValues.thermostatSetpointStatus: + cls_list.append(LyricSetpointStatusSensor) + for cls in cls_list: + entities.append( + cls( + coordinator, + location, + device, + hass.config.units.temperature_unit, + ) + ) + + async_add_entities(entities, True) + + +class LyricSensor(LyricDeviceEntity): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + key: str, + name: str, + icon: str, + device_class: str = None, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + + super().__init__(coordinator, location, device, key, name, icon) + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return self._device_class + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class LyricIndoorTemperatureSensor(LyricSensor): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_indoor_temperature", + "Indoor Temperature", + None, + DEVICE_CLASS_TEMPERATURE, + unit_of_measurement, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self.device.indoorTemperature + + +class LyricOutdoorTemperatureSensor(LyricSensor): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_outdoor_temperature", + "Outdoor Temperature", + None, + DEVICE_CLASS_TEMPERATURE, + unit_of_measurement, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self.device.outdoorTemperature + + +class LyricOutdoorHumiditySensor(LyricSensor): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_outdoor_humidity", + "Outdoor Humidity", + None, + DEVICE_CLASS_HUMIDITY, + "%", + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self.device.displayedOutdoorHumidity + + +class LyricNextPeriodSensor(LyricSensor): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_next_period_time", + "Next Period Time", + None, + DEVICE_CLASS_TIMESTAMP, + ) + + @property + def state(self) -> datetime: + """Return the state of the sensor.""" + device = self.device + time = dt_util.parse_time(device.changeableValues.nextPeriodTime) + now = dt_util.utcnow() + if time <= now.time(): + now = now + timedelta(days=1) + return dt_util.as_utc(datetime.combine(now.date(), time)) + + +class LyricSetpointStatusSensor(LyricSensor): + """Defines a Honeywell Lyric sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + unit_of_measurement: str = None, + ) -> None: + """Initialize Honeywell Lyric sensor.""" + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_setpoint_status", + "Setpoint Status", + "mdi:thermostat", + None, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + device = self.device + if device.changeableValues.thermostatSetpointStatus == PRESET_HOLD_UNTIL: + return f"Held until {device.changeableValues.nextPeriodTime}" + return LYRIC_SETPOINT_STATUS_NAMES.get( + device.changeableValues.thermostatSetpointStatus, "Unknown" + ) diff --git a/homeassistant/components/lyric/services.yaml b/homeassistant/components/lyric/services.yaml new file mode 100644 index 00000000000..b4ea74a9644 --- /dev/null +++ b/homeassistant/components/lyric/services.yaml @@ -0,0 +1,9 @@ +set_hold_time: + description: "Sets the time to hold until" + fields: + entity_id: + description: Name(s) of entities to change + example: "climate.thermostat" + time_period: + description: Time to hold until + example: "01:00:00" diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json new file mode 100644 index 00000000000..4e5f2330840 --- /dev/null +++ b/homeassistant/components/lyric/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/lyric/translations/de.json b/homeassistant/components/lyric/translations/de.json new file mode 100644 index 00000000000..5bab6ed132b --- /dev/null +++ b/homeassistant/components/lyric/translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen." + }, + "create_entry": { + "default": "Erfolgreich authentifiziert" + }, + "step": { + "pick_implementation": { + "title": "W\u00e4hle die Authentifizierungsmethode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/es.json b/homeassistant/components/lyric/translations/es.json new file mode 100644 index 00000000000..db8d744d176 --- /dev/null +++ b/homeassistant/components/lyric/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", + "missing_configuration": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n." + }, + "create_entry": { + "default": "Autenticado correctamente" + }, + "step": { + "pick_implementation": { + "title": "Selecciona el m\u00e9todo de autenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/fr.json b/homeassistant/components/lyric/translations/fr.json new file mode 100644 index 00000000000..540d3e1e6c2 --- /dev/null +++ b/homeassistant/components/lyric/translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." + }, + "create_entry": { + "default": "Authentification r\u00e9ussie" + }, + "step": { + "pick_implementation": { + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/ko.json b/homeassistant/components/lyric/translations/ko.json new file mode 100644 index 00000000000..fa000ea1c06 --- /dev/null +++ b/homeassistant/components/lyric/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + }, + "create_entry": { + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/nl.json b/homeassistant/components/lyric/translations/nl.json new file mode 100644 index 00000000000..d490acb1b59 --- /dev/null +++ b/homeassistant/components/lyric/translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen." + }, + "create_entry": { + "default": "Succesvol geauthenticeerd" + }, + "step": { + "pick_implementation": { + "title": "Kies een authenticatie methode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/ru.json b/homeassistant/components/lyric/translations/ru.json new file mode 100644 index 00000000000..8d41a95fd29 --- /dev/null +++ b/homeassistant/components/lyric/translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438." + }, + "create_entry": { + "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "step": { + "pick_implementation": { + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index e5a0f16863d..5d05596fb23 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -84,7 +84,7 @@ async def async_setup(hass, config): await component.async_add_entities([mailbox_entity]) setup_tasks = [ - async_setup_platform(p_type, p_config) + asyncio.create_task(async_setup_platform(p_type, p_config)) for p_type, p_config in config_per_platform(config, DOMAIN) ] diff --git a/homeassistant/components/mailgun/translations/ko.json b/homeassistant/components/mailgun/translations/ko.json index 43b6586b14f..b757a27f4a0 100644 --- a/homeassistant/components/mailgun/translations/ko.json +++ b/homeassistant/components/mailgun/translations/ko.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun \uc6f9 \ud6c5]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/mailgun/translations/nl.json b/homeassistant/components/mailgun/translations/nl.json index 772a67c118e..dea33946af5 100644 --- a/homeassistant/components/mailgun/translations/nl.json +++ b/homeassistant/components/mailgun/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar Home Assistant te verzenden, moet u [Webhooks with Mailgun]({mailgun_url}) instellen. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Methode: POST \n - Inhoudstype: application/json \n\n Zie [de documentatie]({docs_url}) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py new file mode 100644 index 00000000000..14b33df66c0 --- /dev/null +++ b/homeassistant/components/mazda/__init__.py @@ -0,0 +1,173 @@ +"""The Mazda Connected Services integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from pymazda import ( + Client as MazdaAPI, + MazdaAccountLockedException, + MazdaAPIEncryptionException, + MazdaAuthenticationException, + MazdaException, + MazdaTokenExpiredException, +) + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util.async_ import gather_with_concurrency + +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Mazda Connected Services component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Mazda Connected Services from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + region = entry.data[CONF_REGION] + + websession = aiohttp_client.async_get_clientsession(hass) + mazda_client = MazdaAPI(email, password, region, websession) + + try: + await mazda_client.validate_credentials() + except MazdaAuthenticationException: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) + ) + return False + except ( + MazdaException, + MazdaAccountLockedException, + MazdaTokenExpiredException, + MazdaAPIEncryptionException, + ) as ex: + _LOGGER.error("Error occurred during Mazda login request: %s", ex) + raise ConfigEntryNotReady from ex + + async def async_update_data(): + """Fetch data from Mazda API.""" + + async def with_timeout(task): + async with async_timeout.timeout(10): + return await task + + try: + vehicles = await with_timeout(mazda_client.get_vehicles()) + + vehicle_status_tasks = [ + with_timeout(mazda_client.get_vehicle_status(vehicle["id"])) + for vehicle in vehicles + ] + statuses = await gather_with_concurrency(5, *vehicle_status_tasks) + + for vehicle, status in zip(vehicles, statuses): + vehicle["status"] = status + + return vehicles + except MazdaAuthenticationException as ex: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) + ) + raise UpdateFailed("Not authenticated with Mazda API") from ex + except Exception as ex: + _LOGGER.exception( + "Unknown error occurred during Mazda update request: %s", ex + ) + raise UpdateFailed(ex) from ex + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=timedelta(seconds=60), + ) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: mazda_client, + DATA_COORDINATOR: coordinator, + } + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + # Setup components + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class MazdaEntity(CoordinatorEntity): + """Defines a base Mazda entity.""" + + def __init__(self, coordinator, index): + """Initialize the Mazda entity.""" + super().__init__(coordinator) + self.index = index + self.vin = self.coordinator.data[self.index]["vin"] + + @property + def device_info(self): + """Return device info for the Mazda entity.""" + data = self.coordinator.data[self.index] + return { + "identifiers": {(DOMAIN, self.vin)}, + "name": self.get_vehicle_name(), + "manufacturer": "Mazda", + "model": f"{data['modelYear']} {data['carlineName']}", + } + + def get_vehicle_name(self): + """Return the vehicle name, to be used as a prefix for names of other entities.""" + data = self.coordinator.data[self.index] + if "nickname" in data and len(data["nickname"]) > 0: + return data["nickname"] + return f"{data['modelYear']} {data['carlineName']}" diff --git a/homeassistant/components/mazda/config_flow.py b/homeassistant/components/mazda/config_flow.py new file mode 100644 index 00000000000..53c08b9bd69 --- /dev/null +++ b/homeassistant/components/mazda/config_flow.py @@ -0,0 +1,117 @@ +"""Config flow for Mazda Connected Services integration.""" +import logging + +import aiohttp +from pymazda import ( + Client as MazdaAPI, + MazdaAccountLockedException, + MazdaAuthenticationException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.helpers import aiohttp_client + +# https://github.com/PyCQA/pylint/issues/3202 +from .const import DOMAIN # pylint: disable=unused-import +from .const import MAZDA_REGIONS + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_REGION): vol.In(MAZDA_REGIONS), + } +) + + +class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Mazda Connected Services.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) + + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + mazda_client = MazdaAPI( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input[CONF_REGION], + websession, + ) + await mazda_client.validate_credentials() + except MazdaAuthenticationException: + errors["base"] = "invalid_auth" + except MazdaAccountLockedException: + errors["base"] = "account_locked" + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception( + "Unknown error occurred during Mazda login request: %s", ex + ) + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, user_input=None): + """Perform reauth if the user credentials have changed.""" + errors = {} + + if user_input is not None: + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + mazda_client = MazdaAPI( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input[CONF_REGION], + websession, + ) + await mazda_client.validate_credentials() + except MazdaAuthenticationException: + errors["base"] = "invalid_auth" + except MazdaAccountLockedException: + errors["base"] = "account_locked" + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception( + "Unknown error occurred during Mazda login request: %s", ex + ) + else: + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) + + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, data=user_input + ) + + # Reload the config entry otherwise devices will remain unavailable + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py new file mode 100644 index 00000000000..c75f6bf3b77 --- /dev/null +++ b/homeassistant/components/mazda/const.py @@ -0,0 +1,8 @@ +"""Constants for the Mazda Connected Services integration.""" + +DOMAIN = "mazda" + +DATA_CLIENT = "mazda_client" +DATA_COORDINATOR = "coordinator" + +MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json new file mode 100644 index 00000000000..b3826d42318 --- /dev/null +++ b/homeassistant/components/mazda/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "mazda", + "name": "Mazda Connected Services", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mazda", + "requirements": ["pymazda==0.0.8"], + "codeowners": ["@bdr99"], + "quality_scale": "platinum" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py new file mode 100644 index 00000000000..fa03eb7f410 --- /dev/null +++ b/homeassistant/components/mazda/sensor.py @@ -0,0 +1,263 @@ +"""Platform for Mazda sensor integration.""" +from homeassistant.const import ( + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_PSI, +) + +from . import MazdaEntity +from .const import DATA_COORDINATOR, DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + + entities = [] + + for index, _ in enumerate(coordinator.data): + entities.append(MazdaFuelRemainingSensor(coordinator, index)) + entities.append(MazdaFuelDistanceSensor(coordinator, index)) + entities.append(MazdaOdometerSensor(coordinator, index)) + entities.append(MazdaFrontLeftTirePressureSensor(coordinator, index)) + entities.append(MazdaFrontRightTirePressureSensor(coordinator, index)) + entities.append(MazdaRearLeftTirePressureSensor(coordinator, index)) + entities.append(MazdaRearRightTirePressureSensor(coordinator, index)) + + async_add_entities(entities) + + +class MazdaFuelRemainingSensor(MazdaEntity): + """Class for the fuel remaining sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Fuel Remaining Percentage" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_fuel_remaining_percentage" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PERCENTAGE + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:gas-station" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data[self.index]["status"]["fuelRemainingPercent"] + + +class MazdaFuelDistanceSensor(MazdaEntity): + """Class for the fuel distance sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Fuel Distance Remaining" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_fuel_distance_remaining" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:gas-station" + + @property + def state(self): + """Return the state of the sensor.""" + fuel_distance_km = self.coordinator.data[self.index]["status"][ + "fuelDistanceRemainingKm" + ] + return round(self.hass.config.units.length(fuel_distance_km, LENGTH_KILOMETERS)) + + +class MazdaOdometerSensor(MazdaEntity): + """Class for the odometer sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Odometer" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_odometer" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:speedometer" + + @property + def state(self): + """Return the state of the sensor.""" + odometer_km = self.coordinator.data[self.index]["status"]["odometerKm"] + return round(self.hass.config.units.length(odometer_km, LENGTH_KILOMETERS)) + + +class MazdaFrontLeftTirePressureSensor(MazdaEntity): + """Class for the front left tire pressure sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Front Left Tire Pressure" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_front_left_tire_pressure" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PRESSURE_PSI + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:car-tire-alert" + + @property + def state(self): + """Return the state of the sensor.""" + return round( + self.coordinator.data[self.index]["status"]["tirePressure"][ + "frontLeftTirePressurePsi" + ] + ) + + +class MazdaFrontRightTirePressureSensor(MazdaEntity): + """Class for the front right tire pressure sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Front Right Tire Pressure" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_front_right_tire_pressure" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PRESSURE_PSI + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:car-tire-alert" + + @property + def state(self): + """Return the state of the sensor.""" + return round( + self.coordinator.data[self.index]["status"]["tirePressure"][ + "frontRightTirePressurePsi" + ] + ) + + +class MazdaRearLeftTirePressureSensor(MazdaEntity): + """Class for the rear left tire pressure sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Rear Left Tire Pressure" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_rear_left_tire_pressure" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PRESSURE_PSI + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:car-tire-alert" + + @property + def state(self): + """Return the state of the sensor.""" + return round( + self.coordinator.data[self.index]["status"]["tirePressure"][ + "rearLeftTirePressurePsi" + ] + ) + + +class MazdaRearRightTirePressureSensor(MazdaEntity): + """Class for the rear right tire pressure sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + vehicle_name = self.get_vehicle_name() + return f"{vehicle_name} Rear Right Tire Pressure" + + @property + def unique_id(self): + """Return a unique identifier for this entity.""" + return f"{self.vin}_rear_right_tire_pressure" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return PRESSURE_PSI + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:car-tire-alert" + + @property + def state(self): + """Return the state of the sensor.""" + return round( + self.coordinator.data[self.index]["status"]["tirePressure"][ + "rearRightTirePressurePsi" + ] + ) diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json new file mode 100644 index 00000000000..1950260bfcb --- /dev/null +++ b/homeassistant/components/mazda/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "account_locked": "Account locked. Please try again later.", + "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%]" + }, + "step": { + "reauth": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "description": "Authentication failed for Mazda Connected Services. Please enter your current credentials.", + "title": "Mazda Connected Services - Authentication Failed" + }, + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "description": "Please enter the email address and password you use to log into the MyMazda mobile app.", + "title": "Mazda Connected Services - Add Account" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/ca.json b/homeassistant/components/mazda/translations/ca.json new file mode 100644 index 00000000000..d45b9177c3f --- /dev/null +++ b/homeassistant/components/mazda/translations/ca.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "account_locked": "Compte bloquejat. Intenta-ho m\u00e9s tard.", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya", + "region": "Regi\u00f3" + }, + "description": "Ha fallat l'autenticaci\u00f3 dels Serveis connectats de Mazda. Introdueix les teves credencials actuals.", + "title": "Serveis connectats de Mazda - Ha fallat l'autenticaci\u00f3" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya", + "region": "Regi\u00f3" + }, + "description": "Introdueix el correu electr\u00f2nic i la contrasenya que utilitzes per iniciar sessi\u00f3 a l'aplicaci\u00f3 de m\u00f2bil MyMazda.", + "title": "Serveis connectats de Mazda - Afegeix un compte" + } + } + }, + "title": "Serveis connectats de Mazda" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/cs.json b/homeassistant/components/mazda/translations/cs.json new file mode 100644 index 00000000000..89fde600735 --- /dev/null +++ b/homeassistant/components/mazda/translations/cs.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "reauth": { + "data": { + "email": "E-mail", + "password": "Heslo", + "region": "Region" + } + }, + "user": { + "data": { + "email": "E-mail", + "password": "Heslo", + "region": "Region" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/de.json b/homeassistant/components/mazda/translations/de.json new file mode 100644 index 00000000000..4e23becb8af --- /dev/null +++ b/homeassistant/components/mazda/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": { + "data": { + "email": "E-Mail", + "password": "Passwort", + "region": "Region" + } + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort", + "region": "Region" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/en.json b/homeassistant/components/mazda/translations/en.json new file mode 100644 index 00000000000..b9e02fb3a41 --- /dev/null +++ b/homeassistant/components/mazda/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "account_locked": "Account locked. Please try again later.", + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "reauth": { + "data": { + "email": "Email", + "password": "Password", + "region": "Region" + }, + "description": "Authentication failed for Mazda Connected Services. Please enter your current credentials.", + "title": "Mazda Connected Services - Authentication Failed" + }, + "user": { + "data": { + "email": "Email", + "password": "Password", + "region": "Region" + }, + "description": "Please enter the email address and password you use to log into the MyMazda mobile app.", + "title": "Mazda Connected Services - Add Account" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/es.json b/homeassistant/components/mazda/translations/es.json new file mode 100644 index 00000000000..868ae0d770e --- /dev/null +++ b/homeassistant/components/mazda/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "account_locked": "Cuenta bloqueada. Por favor, int\u00e9ntelo de nuevo m\u00e1s tarde." + }, + "step": { + "reauth": { + "data": { + "password": "Contrase\u00f1a", + "region": "Regi\u00f3n" + }, + "description": "Ha fallado la autenticaci\u00f3n para los Servicios Conectados de Mazda. Por favor, introduce tus credenciales actuales.", + "title": "Servicios Conectados de Mazda - Fallo de autenticaci\u00f3n" + }, + "user": { + "data": { + "email": "Correo electronico", + "password": "Contrase\u00f1a", + "region": "Regi\u00f3n" + }, + "description": "Introduce la direcci\u00f3n de correo electr\u00f3nico y la contrase\u00f1a que utilizas para iniciar sesi\u00f3n en la aplicaci\u00f3n m\u00f3vil MyMazda.", + "title": "Servicios Conectados de Mazda - A\u00f1adir cuenta" + } + } + }, + "title": "Servicios Conectados de Mazda" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/et.json b/homeassistant/components/mazda/translations/et.json new file mode 100644 index 00000000000..4ce2e2fa5f3 --- /dev/null +++ b/homeassistant/components/mazda/translations/et.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "account_locked": "Konto on lukus. Proovi hiljem uuesti.", + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "reauth": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na", + "region": "Piirkond" + }, + "description": "Mazda Connected Services tuvastamine nurjus. Sisesta oma kehtivad andmed.", + "title": "Mazda Connected Services - tuvastamine nurjus" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na", + "region": "Piirkond" + }, + "description": "Sisesta e-posti aadress ja salas\u00f5na mida kasutad MyMazda mobiilirakendusse sisselogimiseks.", + "title": "Mazda Connected Services - lisa konto" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/fr.json b/homeassistant/components/mazda/translations/fr.json new file mode 100644 index 00000000000..aa1ea252c0c --- /dev/null +++ b/homeassistant/components/mazda/translations/fr.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9ja configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "account_locked": "Compte bloqu\u00e9. Veuillez r\u00e9essayer plus tard.", + "cannot_connect": "Echec de la connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth": { + "data": { + "email": "Email", + "password": "Mot de passe", + "region": "R\u00e9gion" + }, + "description": "L'authentification a \u00e9chou\u00e9 pour les services connect\u00e9s Mazda. Veuillez saisir vos informations d'identification actuelles.", + "title": "Services connect\u00e9s Mazda - \u00c9chec de l'authentification" + }, + "user": { + "data": { + "email": "Email", + "password": "Mot de passe", + "region": "R\u00e9gion" + }, + "description": "Veuillez saisir l'adresse e-mail et le mot de passe que vous utilisez pour vous connecter \u00e0 l'application mobile MyMazda.", + "title": "Services connect\u00e9s Mazda - Ajouter un compte" + } + } + }, + "title": "Services connect\u00e9s Mazda" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/it.json b/homeassistant/components/mazda/translations/it.json new file mode 100644 index 00000000000..d5a2796ed18 --- /dev/null +++ b/homeassistant/components/mazda/translations/it.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "account_locked": "Account bloccato. Per favore riprova pi\u00f9 tardi.", + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth": { + "data": { + "email": "E-mail", + "password": "Password", + "region": "Area geografica" + }, + "description": "Autenticazione non riuscita per Mazda Connected Services. Inserisci le tue credenziali attuali.", + "title": "Mazda Connected Services - Autenticazione non riuscita" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Password", + "region": "Area geografica" + }, + "description": "Inserisci l'indirizzo e-mail e la password che utilizzi per accedere all'app mobile MyMazda.", + "title": "Mazda Connected Services - Aggiungi account" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/ko.json b/homeassistant/components/mazda/translations/ko.json new file mode 100644 index 00000000000..31495b0d8e3 --- /dev/null +++ b/homeassistant/components/mazda/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "reauth": { + "data": { + "email": "\uc774\uba54\uc77c", + "password": "\ube44\ubc00\ubc88\ud638" + } + }, + "user": { + "data": { + "email": "\uc774\uba54\uc77c", + "password": "\ube44\ubc00\ubc88\ud638" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/nl.json b/homeassistant/components/mazda/translations/nl.json new file mode 100644 index 00000000000..3198bfb4192 --- /dev/null +++ b/homeassistant/components/mazda/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "account_locked": "Account vergrendeld. Probeer het later nog eens.", + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + } + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord", + "region": "Regio" + } + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/no.json b/homeassistant/components/mazda/translations/no.json new file mode 100644 index 00000000000..e3a05de51f9 --- /dev/null +++ b/homeassistant/components/mazda/translations/no.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "account_locked": "Kontoen er l\u00e5st. Pr\u00f8v igjen senere.", + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "reauth": { + "data": { + "email": "E-post", + "password": "Passord", + "region": "Region" + }, + "description": "Autentisering mislyktes for Mazda Connected Services. Vennligst skriv inn din n\u00e5v\u00e6rende legitimasjon.", + "title": "Mazda Connected Services - Autentisering mislyktes" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord", + "region": "Region" + }, + "description": "Vennligst skriv inn e-postadressen og passordet du bruker for \u00e5 logge p\u00e5 MyMazda-mobilappen.", + "title": "Mazda Connected Services - Legg til konto" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/pl.json b/homeassistant/components/mazda/translations/pl.json new file mode 100644 index 00000000000..12254f20662 --- /dev/null +++ b/homeassistant/components/mazda/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "account_locked": "Konto zablokowane. Spr\u00f3buj ponownie p\u00f3\u017aniej.", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o", + "region": "Region" + }, + "description": "Uwierzytelnianie dla Mazda Connected Services nie powiod\u0142o si\u0119. Wprowad\u017a aktualne dane uwierzytelniaj\u0105ce.", + "title": "Mazda Connected Services - Uwierzytelnianie nie powiod\u0142o si\u0119" + }, + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o", + "region": "Region" + }, + "description": "Wprowad\u017a adres e-mail i has\u0142o, kt\u00f3rych u\u017cywasz do logowania si\u0119 do aplikacji mobilnej MyMazda.", + "title": "Mazda Connected Services - Dodawanie konta" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/ru.json b/homeassistant/components/mazda/translations/ru.json new file mode 100644 index 00000000000..be3f861d406 --- /dev/null +++ b/homeassistant/components/mazda/translations/ru.json @@ -0,0 +1,35 @@ +{ + "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": { + "account_locked": "\u0410\u043a\u043a\u0430\u0443\u043d\u0442 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d. \u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "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": { + "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", + "region": "\u0420\u0435\u0433\u0438\u043e\u043d" + }, + "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. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "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": { + "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", + "region": "\u0420\u0435\u0433\u0438\u043e\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0412\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0435 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u0432 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 MyMazda.", + "title": "Mazda Connected Services" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/zh-Hant.json b/homeassistant/components/mazda/translations/zh-Hant.json new file mode 100644 index 00000000000..48232664683 --- /dev/null +++ b/homeassistant/components/mazda/translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "account_locked": "\u5e33\u865f\u5df2\u9396\u5b9a\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66\u3002", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc", + "region": "\u5340\u57df" + }, + "description": "Mazda Connected \u670d\u52d9\u8a8d\u8b49\u5931\u6557\u3002\u8acb\u8f38\u5165\u76ee\u524d\u6191\u8b49\u3002", + "title": "Mazda Connected \u670d\u52d9 - \u8a8d\u8b49\u5931\u6557" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc", + "region": "\u5340\u57df" + }, + "description": "\u8acb\u8f38\u5165\u767b\u5165MyMazda \u884c\u52d5 App \u4e4b Email \u5730\u5740\u8207\u5bc6\u78bc\u3002", + "title": "Mazda Connected \u670d\u52d9 - \u65b0\u589e\u5e33\u865f" + } + } + }, + "title": "Mazda Connected \u670d\u52d9" +} \ No newline at end of file diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 5a09171df80..c6ee6ccb8a4 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2021.01.16"], + "requirements": ["youtube_dl==2021.01.24.1"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml index 17abffee89d..1e58c19baf1 100644 --- a/homeassistant/components/media_extractor/services.yaml +++ b/homeassistant/components/media_extractor/services.yaml @@ -1,5 +1,5 @@ play_media: - description: Downloads file from given url. + description: Downloads file from given URL. fields: entity_id: description: Name(s) of entities to play media on. diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d670acb7af9..87ecff7a54c 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1,4 +1,6 @@ """Component to interface with various media players.""" +from __future__ import annotations + import asyncio import base64 import collections @@ -851,7 +853,7 @@ class MediaPlayerEntity(Entity): self, media_content_type: Optional[str] = None, media_content_id: Optional[str] = None, - ) -> "BrowseMedia": + ) -> BrowseMedia: """Return a BrowseMedia instance. The BrowseMedia instance will be used by the diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py new file mode 100644 index 00000000000..6db5f16cf01 --- /dev/null +++ b/homeassistant/components/media_player/device_trigger.py @@ -0,0 +1,90 @@ +"""Provides device automations for Media player.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import state as state_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +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 = {"turned_on", "turned_off", "idle", "paused", "playing"} + +TRIGGER_SCHEMA = 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]: + """List device triggers for Media player entities.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integration entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + # Add triggers for each entity that belongs to this integration + triggers += [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: trigger, + } + for trigger in TRIGGER_TYPES + ] + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == "turned_on": + to_state = STATE_ON + elif config[CONF_TYPE] == "turned_off": + to_state = STATE_OFF + elif config[CONF_TYPE] == "idle": + to_state = STATE_IDLE + elif config[CONF_TYPE] == "paused": + to_state = STATE_PAUSED + else: + to_state = STATE_PLAYING + + state_config = { + CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_trigger.CONF_TO: to_state, + } + state_config = state_trigger.TRIGGER_SCHEMA(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/services.yaml b/homeassistant/components/media_player/services.yaml index 08637df0745..eaca8483be1 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -1,168 +1,186 @@ # Describes the format for available media player services turn_on: + name: Turn on description: Turn a media player power on. - fields: - entity_id: - description: Name(s) of entities to turn on. - example: "media_player.living_room_chromecast" + target: turn_off: + name: Turn off description: Turn a media player power off. - fields: - entity_id: - description: Name(s) of entities to turn off. - example: "media_player.living_room_chromecast" + target: toggle: + name: Toggle description: Toggles a media player power state. - fields: - entity_id: - description: Name(s) of entities to toggle. - example: "media_player.living_room_chromecast" + target: volume_up: + name: Turn up volume description: Turn a media player volume up. - fields: - entity_id: - description: Name(s) of entities to turn volume up on. - example: "media_player.living_room_sonos" + target: volume_down: + name: Turn down volume description: Turn a media player volume down. - fields: - entity_id: - description: Name(s) of entities to turn volume down on. - example: "media_player.living_room_sonos" + target: volume_mute: + name: Mute volume description: Mute a media player's volume. + target: fields: - entity_id: - description: Name(s) of entities to mute. - example: "media_player.living_room_sonos" is_volume_muted: + name: Muted description: True/false for mute/unmute. + required: true example: true + selector: + boolean: volume_set: + name: Set volume description: Set a media player's volume level. + target: fields: - entity_id: - description: Name(s) of entities to set volume level on. - example: "media_player.living_room_sonos" volume_level: + name: Level description: Volume level to set as float. + required: true example: 0.6 + selector: + number: + min: 0 + max: 1 + step: 0.01 + mode: slider media_play_pause: + name: Play/Pause description: Toggle media player play/pause state. - fields: - entity_id: - description: Name(s) of entities to toggle play/pause state on. - example: "media_player.living_room_sonos" + target: media_play: + name: Play description: Send the media player the command for play. - fields: - entity_id: - description: Name(s) of entities to play on. - example: "media_player.living_room_sonos" + target: media_pause: + name: Pause description: Send the media player the command for pause. - fields: - entity_id: - description: Name(s) of entities to pause on. - example: "media_player.living_room_sonos" + target: media_stop: + name: Stop description: Send the media player the stop command. - fields: - entity_id: - description: Name(s) of entities to stop on. - example: "media_player.living_room_sonos" + target: media_next_track: + name: Next description: Send the media player the command for next track. - fields: - entity_id: - description: Name(s) of entities to send next track command to. - example: "media_player.living_room_sonos" + target: media_previous_track: + name: Previous description: Send the media player the command for previous track. - fields: - entity_id: - description: Name(s) of entities to send previous track command to. - example: "media_player.living_room_sonos" + target: media_seek: - description: Send the media player the command to seek in current playing media. + name: Seek + description: + Send the media player the command to seek in current playing media. fields: - entity_id: - description: Name(s) of entities to seek media on. - example: "media_player.living_room_chromecast" seek_position: + name: Position description: Position to seek to. The format is platform dependent. + required: true example: 100 + selector: + number: + min: 0 + max: 9223372036854775807 + step: 0.01 + mode: box play_media: + name: Play media description: Send the media player the command for playing media. + target: fields: - entity_id: - description: Name(s) of entities to seek media on - example: "media_player.living_room_chromecast" media_content_id: + name: Content ID description: The ID of the content to play. Platform dependent. + required: true example: "https://home-assistant.io/images/cast/splash.png" + selector: + text: + media_content_type: - description: The type of the content to play. Must be one of image, music, tvshow, video, episode, channel or playlist + name: Content type + description: + The type of the content to play. Like image, music, tvshow, + video, episode, channel or playlist. + required: true example: "music" + selector: + text: select_source: + name: Select source description: Send the media player the command to change input source. + target: fields: - entity_id: - description: Name(s) of entities to change source on. - example: "media_player.txnr535_0009b0d81f82" source: + name: Source description: Name of the source to switch to. Platform dependent. + required: true example: "video1" + selector: + text: select_sound_mode: + name: Select sound mode description: Send the media player the command to change sound mode. + target: fields: - entity_id: - description: Name(s) of entities to change sound mode on. - example: "media_player.marantz" sound_mode: + name: Sound mode description: Name of the sound mode to switch to. example: "Music" + selector: + text: clear_playlist: + name: Clear playlist description: Send the media player the command to clear players playlist. - fields: - entity_id: - description: Name(s) of entities to change source on. - example: "media_player.living_room_chromecast" + target: shuffle_set: + name: Shuffle description: Set shuffling state. + target: fields: - entity_id: - description: Name(s) of entities to set. - example: "media_player.spotify" shuffle: + name: Shuffle description: True/false for enabling/disabling shuffle. + required: true example: true + selector: + boolean: repeat_set: - description: Set repeat mode. + name: Repeat + description: Set repeat mode + target: fields: - entity_id: - description: Name(s) of entities to set. - example: "media_player.sonos" repeat: + name: Repeat mode description: Repeat mode to set (off, all, one). + required: true example: "off" + selector: + select: + options: + - "off" + - "all" + - "one" diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 14f1eea131c..64841413f12 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -7,6 +7,13 @@ "is_idle": "{entity_name} is idle", "is_paused": "{entity_name} is paused", "is_playing": "{entity_name} is playing" + }, + "trigger_type": { + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off", + "idle": "{entity_name} becomes idle", + "paused": "{entity_name} is paused", + "playing": "{entity_name} starts playing" } }, "state": { diff --git a/homeassistant/components/media_player/translations/ca.json b/homeassistant/components/media_player/translations/ca.json index 67f7aad655b..e1fce334053 100644 --- a/homeassistant/components/media_player/translations/ca.json +++ b/homeassistant/components/media_player/translations/ca.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} est\u00e0 enc\u00e8s", "is_paused": "{entity_name} est\u00e0 en pausa", "is_playing": "{entity_name} est\u00e0 reproduint" + }, + "trigger_type": { + "idle": "{entity_name} es torna inactiu", + "paused": "{entity_name} est\u00e0 en pausa", + "playing": "{entity_name} comen\u00e7a a reproduir", + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha engegat" } }, "state": { diff --git a/homeassistant/components/media_player/translations/de.json b/homeassistant/components/media_player/translations/de.json index a7f25fa9d7c..4909c85d053 100644 --- a/homeassistant/components/media_player/translations/de.json +++ b/homeassistant/components/media_player/translations/de.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} ist eingeschaltet", "is_paused": "{entity_name} ist pausiert", "is_playing": "{entity_name} spielt" + }, + "trigger_type": { + "idle": "{entity_name} wird inaktiv", + "paused": "{entity_name} ist angehalten", + "playing": "{entity_name} beginnt zu spielen", + "turned_off": "{entity_name} ausgeschaltet", + "turned_on": "{entity_name} eingeschaltet" } }, "state": { diff --git a/homeassistant/components/media_player/translations/en.json b/homeassistant/components/media_player/translations/en.json index 3a96a2b3a90..aa995be9904 100644 --- a/homeassistant/components/media_player/translations/en.json +++ b/homeassistant/components/media_player/translations/en.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} is on", "is_paused": "{entity_name} is paused", "is_playing": "{entity_name} is playing" + }, + "trigger_type": { + "idle": "{entity_name} becomes idle", + "paused": "{entity_name} is paused", + "playing": "{entity_name} starts playing", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, "state": { diff --git a/homeassistant/components/media_player/translations/es.json b/homeassistant/components/media_player/translations/es.json index fffaedc1d97..f1ffc44957e 100644 --- a/homeassistant/components/media_player/translations/es.json +++ b/homeassistant/components/media_player/translations/es.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} est\u00e1 activado", "is_paused": "{entity_name} est\u00e1 en pausa", "is_playing": "{entity_name} est\u00e1 reproduciendo" + }, + "trigger_type": { + "idle": "{entity_name} est\u00e1 inactivo", + "paused": "{entity_name} est\u00e1 en pausa", + "playing": "{entity_name} comienza a reproducirse", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado" } }, "state": { diff --git a/homeassistant/components/media_player/translations/et.json b/homeassistant/components/media_player/translations/et.json index 4d71a30a8ac..687a0e5953d 100644 --- a/homeassistant/components/media_player/translations/et.json +++ b/homeassistant/components/media_player/translations/et.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} on sisse l\u00fclitatud", "is_paused": "{entity_name} on peatatud", "is_playing": "{entity_name} m\u00e4ngib" + }, + "trigger_type": { + "idle": "{entity_name} muutub j\u00f5udeolekusse", + "paused": "{entity_name} on pausil", + "playing": "{entity_name} alustab taasesitamist", + "turned_off": "{entity_name} l\u00fclitus v\u00e4lja", + "turned_on": "{entity_name} l\u00fclitus sisse" } }, "state": { diff --git a/homeassistant/components/media_player/translations/fr.json b/homeassistant/components/media_player/translations/fr.json index f3992f74616..9ecdd19037f 100644 --- a/homeassistant/components/media_player/translations/fr.json +++ b/homeassistant/components/media_player/translations/fr.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} est activ\u00e9", "is_paused": "{entity_name} est en pause", "is_playing": "{entity_name} joue" + }, + "trigger_type": { + "idle": "{entity_name} devient inactif", + "paused": "{entity_name} est mis en pause", + "playing": "{entity_name} commence \u00e0 jouer", + "turned_off": "{entity_name} d\u00e9sactiv\u00e9", + "turned_on": "{entity_name} activ\u00e9" } }, "state": { diff --git a/homeassistant/components/media_player/translations/it.json b/homeassistant/components/media_player/translations/it.json index 23d1afa0625..a3ebfdfe411 100644 --- a/homeassistant/components/media_player/translations/it.json +++ b/homeassistant/components/media_player/translations/it.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} \u00e8 acceso", "is_paused": "{entity_name} \u00e8 in pausa", "is_playing": "{entity_name} \u00e8 in esecuzione" + }, + "trigger_type": { + "idle": "{entity_name} diventa inattivo", + "paused": "{entity_name} \u00e8 in pausa", + "playing": "{entity_name} inizia l'esecuzione", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" } }, "state": { diff --git a/homeassistant/components/media_player/translations/nl.json b/homeassistant/components/media_player/translations/nl.json index 5e690f35f8a..6ad22742533 100644 --- a/homeassistant/components/media_player/translations/nl.json +++ b/homeassistant/components/media_player/translations/nl.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} is ingeschakeld", "is_paused": "{entity_name} is gepauzeerd", "is_playing": "{entity_name} wordt afgespeeld" + }, + "trigger_type": { + "idle": "{entity_name} wordt inactief", + "paused": "{entity_name} is gepauzeerd", + "playing": "{entity_name} begint te spelen", + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld" } }, "state": { @@ -15,7 +22,7 @@ "on": "Aan", "paused": "Gepauzeerd", "playing": "Afspelen", - "standby": "Standby" + "standby": "Stand-by" } }, "title": "Mediaspeler" diff --git a/homeassistant/components/media_player/translations/no.json b/homeassistant/components/media_player/translations/no.json index 691ec894a7b..fa5618efc35 100644 --- a/homeassistant/components/media_player/translations/no.json +++ b/homeassistant/components/media_player/translations/no.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} er sl\u00e5tt p\u00e5", "is_paused": "{entity_name} er satt p\u00e5 pause", "is_playing": "{entity_name} spiller n\u00e5" + }, + "trigger_type": { + "idle": "{entity_name} blir inaktiv", + "paused": "{entity_name} er satt p\u00e5 pause", + "playing": "{entity_name} begynner \u00e5 spille", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" } }, "state": { diff --git a/homeassistant/components/media_player/translations/pl.json b/homeassistant/components/media_player/translations/pl.json index 23ba46f9339..2a70661d788 100644 --- a/homeassistant/components/media_player/translations/pl.json +++ b/homeassistant/components/media_player/translations/pl.json @@ -6,6 +6,13 @@ "is_on": "odtwarzacz {entity_name} jest w\u0142\u0105czony", "is_paused": "odtwarzanie medi\u00f3w na {entity_name} jest wstrzymane", "is_playing": "{entity_name} odtwarza media" + }, + "trigger_type": { + "idle": "odtwarzacz {entity_name} stanie si\u0119 bezczynny", + "paused": "odtwarzacz {entity_name} zostanie wstrzymany", + "playing": "odtwarzacz {entity_name} rozpocznie odtwarzanie", + "turned_off": "odtwarzacz {entity_name} zostanie wy\u0142\u0105czony", + "turned_on": "odtwarzacz {entity_name} zostanie w\u0142\u0105czony" } }, "state": { diff --git a/homeassistant/components/media_player/translations/ru.json b/homeassistant/components/media_player/translations/ru.json index 8ed46953675..df0b00d2482 100644 --- a/homeassistant/components/media_player/translations/ru.json +++ b/homeassistant/components/media_player/translations/ru.json @@ -6,6 +6,13 @@ "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_paused": "{entity_name} \u043d\u0430 \u043f\u0430\u0443\u0437\u0435", "is_playing": "{entity_name} \u0432\u043e\u0441\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442 \u043c\u0435\u0434\u0438\u0430" + }, + "trigger_type": { + "idle": "{entity_name} \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0438\u0442 \u0432 \u0440\u0435\u0436\u0438\u043c \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f", + "paused": "{entity_name} \u043d\u0430 \u043f\u0430\u0443\u0437\u0435", + "playing": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u0432\u043e\u0441\u043f\u0440\u043e\u0438\u0437\u0432\u0435\u0434\u0435\u043d\u0438\u0435", + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" } }, "state": { diff --git a/homeassistant/components/media_player/translations/tr.json b/homeassistant/components/media_player/translations/tr.json index 1f46c6a8bc7..f7b9be9da53 100644 --- a/homeassistant/components/media_player/translations/tr.json +++ b/homeassistant/components/media_player/translations/tr.json @@ -3,6 +3,10 @@ "condition_type": { "is_idle": "{entity_name} bo\u015fta", "is_off": "{entity_name} kapal\u0131" + }, + "trigger_type": { + "playing": "{entity_name} oynamaya ba\u015flar", + "turned_off": "{entity_name} kapat\u0131ld\u0131" } }, "state": { diff --git a/homeassistant/components/media_player/translations/zh-Hant.json b/homeassistant/components/media_player/translations/zh-Hant.json index 3ae786cbed9..a3a4b82380e 100644 --- a/homeassistant/components/media_player/translations/zh-Hant.json +++ b/homeassistant/components/media_player/translations/zh-Hant.json @@ -6,6 +6,13 @@ "is_on": "{entity_name}\u958b\u555f", "is_paused": "{entity_name}\u5df2\u66ab\u505c", "is_playing": "{entity_name}\u6b63\u5728\u64ad\u653e" + }, + "trigger_type": { + "idle": "{entity_name}\u8b8a\u6210\u9592\u7f6e", + "paused": "{entity_name}\u5df2\u66ab\u505c", + "playing": "{entity_name}\u958b\u59cb\u64ad\u653e", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } }, "state": { diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index e16ecbe578e..98b817344d9 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -1,4 +1,6 @@ """Media Source models.""" +from __future__ import annotations + from abc import ABC from dataclasses import dataclass from typing import List, Optional, Tuple @@ -82,12 +84,12 @@ class MediaSourceItem: return await self.async_media_source().async_resolve_media(self) @callback - def async_media_source(self) -> "MediaSource": + def async_media_source(self) -> MediaSource: """Return media source that owns this item.""" return self.hass.data[DOMAIN][self.domain] @classmethod - def from_uri(cls, hass: HomeAssistant, uri: str) -> "MediaSourceItem": + def from_uri(cls, hass: HomeAssistant, uri: str) -> MediaSourceItem: """Create an item from a uri.""" match = URI_SCHEME_REGEX.match(uri) diff --git a/homeassistant/components/melcloud/translations/ko.json b/homeassistant/components/melcloud/translations/ko.json index a43d4cfbcb3..2e1f1b535e1 100644 --- a/homeassistant/components/melcloud/translations/ko.json +++ b/homeassistant/components/melcloud/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uc774 \uc774\uba54\uc77c\uc5d0 \ub300\ud55c MELCloud \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uac31\uc2e0\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/melcloud/translations/ru.json b/homeassistant/components/melcloud/translations/ru.json index e904ea4e8b7..5c5081cb0c6 100644 --- a/homeassistant/components/melcloud/translations/ru.json +++ b/homeassistant/components/melcloud/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/melcloud/translations/zh-Hant.json b/homeassistant/components/melcloud/translations/zh-Hant.json index 9947b5ac990..27f4d0e5d7f 100644 --- a/homeassistant/components/melcloud/translations/zh-Hant.json +++ b/homeassistant/components/melcloud/translations/zh-Hant.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u5df2\u4f7f\u7528\u6b64\u90f5\u4ef6\u8a2d\u5b9a MELCloud \u6574\u5408\u3002\u5b58\u53d6\u5bc6\u9470\u5df2\u66f4\u65b0\u3002" + "already_configured": "\u5df2\u4f7f\u7528\u6b64\u90f5\u4ef6\u8a2d\u5b9a MELCloud \u6574\u5408\u3002\u5b58\u53d6\u6b0a\u6756\u5df2\u66f4\u65b0\u3002" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 8c507eb0b8d..b78c412393d 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -1,6 +1,4 @@ """Constants for Met component.""" -import logging - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -191,5 +189,3 @@ ATTR_MAP = { ATTR_WEATHER_WIND_BEARING: "wind_bearing", ATTR_WEATHER_WIND_SPEED: "wind_speed", } - -_LOGGER = logging.getLogger(".") diff --git a/homeassistant/components/met/translations/ko.json b/homeassistant/components/met/translations/ko.json index e7263aba3d2..17175c196c0 100644 --- a/homeassistant/components/met/translations/ko.json +++ b/homeassistant/components/met/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/meteo_france/translations/ko.json b/homeassistant/components/meteo_france/translations/ko.json index 4b8dc3204dd..ec48103bbff 100644 --- a/homeassistant/components/meteo_france/translations/ko.json +++ b/homeassistant/components/meteo_france/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "\ub3c4\uc2dc\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "empty": "\ub3c4\uc2dc \uac80\uc0c9 \uacb0\uacfc \uc5c6\uc74c: \ub3c4\uc2dc \ud544\ub4dc\ub97c \ud655\uc778\ud558\uc2ed\uc2dc\uc624." diff --git a/homeassistant/components/meteo_france/translations/nl.json b/homeassistant/components/meteo_france/translations/nl.json index 27dfb56f8d7..61925da4cd3 100644 --- a/homeassistant/components/meteo_france/translations/nl.json +++ b/homeassistant/components/meteo_france/translations/nl.json @@ -5,6 +5,9 @@ "unknown": "Onbekende fout: probeer het later nog eens" }, "step": { + "cities": { + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "Stad" diff --git a/homeassistant/components/metoffice/translations/ko.json b/homeassistant/components/metoffice/translations/ko.json index b1af2afaf30..b2f09a4a9e5 100644 --- a/homeassistant/components/metoffice/translations/ko.json +++ b/homeassistant/components/metoffice/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "\uc601\uad6d \uae30\uc0c1\uccad DataPoint API \ud0a4", + "api_key": "API \ud0a4", "latitude": "\uc704\ub3c4", "longitude": "\uacbd\ub3c4" }, diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index c349589ebdd..1e1c088b351 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -6,7 +6,7 @@ from pycsspeechtts import pycsspeechtts import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider -from homeassistant.const import CONF_API_KEY, CONF_TYPE, PERCENTAGE +from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE import homeassistant.helpers.config_validation as cv CONF_GENDER = "gender" @@ -15,8 +15,6 @@ CONF_RATE = "rate" CONF_VOLUME = "volume" CONF_PITCH = "pitch" CONF_CONTOUR = "contour" -CONF_REGION = "region" - _LOGGER = logging.getLogger(__name__) SUPPORTED_LANGUAGES = [ @@ -56,6 +54,7 @@ SUPPORTED_LANGUAGES = [ "ro-ro", "ru-ru", "sk-sk", + "sl-si", "sv-se", "th-th", "tr-tr", diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 69a738724c3..b9046429603 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -275,7 +275,9 @@ class MicrosoftFace: for person in persons: self._store[g_id][person["name"]] = person["personId"] - tasks.append(self._entities[g_id].async_update_ha_state()) + tasks.append( + asyncio.create_task(self._entities[g_id].async_update_ha_state()) + ) if tasks: await asyncio.wait(tasks) diff --git a/homeassistant/components/mikrotik/translations/ko.json b/homeassistant/components/mikrotik/translations/ko.json index f32e2260501..05a8f50066c 100644 --- a/homeassistant/components/mikrotik/translations/ko.json +++ b/homeassistant/components/mikrotik/translations/ko.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured": "Mikrotik \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/mikrotik/translations/ru.json b/homeassistant/components/mikrotik/translations/ru.json index 868ed49b5c4..21391f12b1c 100644 --- a/homeassistant/components/mikrotik/translations/ru.json +++ b/homeassistant/components/mikrotik/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." }, "step": { diff --git a/homeassistant/components/mill/translations/ko.json b/homeassistant/components/mill/translations/ko.json index d2c6fd74284..48c8cdc6eaa 100644 --- a/homeassistant/components/mill/translations/ko.json +++ b/homeassistant/components/mill/translations/ko.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/mill/translations/nl.json b/homeassistant/components/mill/translations/nl.json index 4699b6fb733..fff0a8232e4 100644 --- a/homeassistant/components/mill/translations/nl.json +++ b/homeassistant/components/mill/translations/nl.json @@ -3,10 +3,13 @@ "abort": { "already_configured": "Account is al geconfigureerd" }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, "step": { "user": { "data": { - "password": "Password", + "password": "Wachtwoord", "username": "Gebruikersnaam" } } diff --git a/homeassistant/components/minecraft_server/translations/et.json b/homeassistant/components/minecraft_server/translations/et.json index a92449b0512..f8de21662aa 100644 --- a/homeassistant/components/minecraft_server/translations/et.json +++ b/homeassistant/components/minecraft_server/translations/et.json @@ -4,7 +4,7 @@ "already_configured": "Teenus on juba seadistatud" }, "error": { - "cannot_connect": "Serveriga \u00fchenduse loomine nurjus. Kontrollige hosti ja porti ning proovige uuesti. Samuti veenduge, et kasutate oma serveris v\u00e4hemalt Minecrafti versiooni 1.7.", + "cannot_connect": "Serveriga \u00fchenduse loomine nurjus. Kontrolli hosti ja porti ning proovi uuesti. Samuti veendu, et kasutad oma serveris v\u00e4hemalt Minecrafti versiooni 1.7.", "invalid_ip": "IP-aadress on vale (MAC-aadressi ei \u00f5nnestunud tuvastada). Paranda ja proovi uuesti.", "invalid_port": "Lubatud pordivahemik on 1024\u201365535. Paranda ja proovi uuesti." }, diff --git a/homeassistant/components/minecraft_server/translations/ko.json b/homeassistant/components/minecraft_server/translations/ko.json index 30605d72936..98ab72e94fc 100644 --- a/homeassistant/components/minecraft_server/translations/ko.json +++ b/homeassistant/components/minecraft_server/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "cannot_connect": "\uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\ub97c \ud655\uc778\ud55c \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \ub610\ud55c \uc11c\ubc84\uc5d0\uc11c Minecraft \ubc84\uc804 1.7 \uc774\uc0c1\uc744 \uc2e4\ud589 \uc911\uc778\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index 6b64c88c1ce..244a0c410d5 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_MAC, CONF_MONITORED_CONDITIONS, CONF_NAME, + CONF_TIMEOUT, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -34,7 +35,6 @@ CONF_ADAPTER = "adapter" CONF_CACHE = "cache_value" CONF_MEDIAN = "median" CONF_RETRIES = "retries" -CONF_TIMEOUT = "timeout" DEFAULT_ADAPTER = "hci0" DEFAULT_UPDATE_INTERVAL = 300 diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 3bc95bf3e05..54fa3398ee2 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -17,11 +17,9 @@ from .const import ( ATTR_MODEL, ATTR_OS_VERSION, CONF_CLOUDHOOK_URL, - DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, - DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, @@ -40,18 +38,14 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): app_config = await store.async_load() if app_config is None: app_config = { - DATA_BINARY_SENSOR: {}, DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], - DATA_SENSOR: {}, } hass.data[DOMAIN] = { - DATA_BINARY_SENSOR: app_config.get(DATA_BINARY_SENSOR, {}), DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, - DATA_SENSOR: app_config.get(DATA_SENSOR, {}), DATA_STORE: store, } diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index ae8efc0c113..36897dd9f69 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -2,18 +2,25 @@ from functools import partial from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID, STATE_ON from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + ATTR_DEVICE_NAME, + ATTR_SENSOR_ATTRIBUTES, + ATTR_SENSOR_DEVICE_CLASS, + ATTR_SENSOR_ICON, + ATTR_SENSOR_NAME, ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE, ATTR_SENSOR_TYPE_BINARY_SENSOR as ENTITY_TYPE, ATTR_SENSOR_UNIQUE_ID, DATA_DEVICES, DOMAIN, ) -from .entity import MobileAppEntity, sensor_id +from .entity import MobileAppEntity, unique_id async def async_setup_entry(hass, config_entry, async_add_entities): @@ -22,13 +29,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): webhook_id = config_entry.data[CONF_WEBHOOK_ID] - for config in hass.data[DOMAIN][ENTITY_TYPE].values(): - if config[CONF_WEBHOOK_ID] != webhook_id: + entity_registry = await er.async_get_registry(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 - - device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] - - entities.append(MobileAppBinarySensor(config, device, config_entry)) + config = { + ATTR_SENSOR_ATTRIBUTES: {}, + ATTR_SENSOR_DEVICE_CLASS: entry.device_class, + ATTR_SENSOR_ICON: entry.original_icon, + ATTR_SENSOR_NAME: entry.original_name, + ATTR_SENSOR_STATE: None, + ATTR_SENSOR_TYPE: entry.domain, + ATTR_SENSOR_UNIQUE_ID: entry.unique_id, + } + entities.append(MobileAppBinarySensor(config, entry.device_id, config_entry)) async_add_entities(entities) @@ -37,14 +52,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if data[CONF_WEBHOOK_ID] != webhook_id: return - unique_id = sensor_id(data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID]) - - entity = hass.data[DOMAIN][ENTITY_TYPE][unique_id] - - if "added" in entity: - return - - entity["added"] = True + data[CONF_UNIQUE_ID] = unique_id( + data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID] + ) + data[ + CONF_NAME + ] = f"{config_entry.data[ATTR_DEVICE_NAME]} {data[ATTR_SENSOR_NAME]}" device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] @@ -64,3 +77,10 @@ class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): def is_on(self): """Return the state of the binary sensor.""" return self._config[ATTR_SENSOR_STATE] + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + + super().async_restore_last_state(last_state) + self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py index 08fdecf364d..80b6c8db5e1 100644 --- a/homeassistant/components/mobile_app/config_flow.py +++ b/homeassistant/components/mobile_app/config_flow.py @@ -3,7 +3,7 @@ import uuid from homeassistant import config_entries from homeassistant.components import person -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .const import ATTR_APP_ID, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, CONF_USER_ID, DOMAIN @@ -36,8 +36,8 @@ class MobileAppFlowHandler(config_entries.ConfigFlow): user_input[ATTR_DEVICE_ID] = str(uuid.uuid4()).replace("-", "") # Register device tracker entity and add to person registering app - ent_reg = await entity_registry.async_get_registry(self.hass) - devt_entry = ent_reg.async_get_or_create( + entity_registry = await er.async_get_registry(self.hass) + devt_entry = entity_registry.async_get_or_create( "device_tracker", DOMAIN, user_input[ATTR_DEVICE_ID], diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index b35468a6fb3..b603e117c4c 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -9,11 +9,9 @@ CONF_REMOTE_UI_URL = "remote_ui_url" CONF_SECRET = "secret" CONF_USER_ID = "user_id" -DATA_BINARY_SENSOR = "binary_sensor" DATA_CONFIG_ENTRIES = "config_entries" DATA_DELETED_IDS = "deleted_ids" DATA_DEVICES = "devices" -DATA_SENSOR = "sensor" DATA_STORE = "store" DATA_NOTIFY = "notify" diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 7a12f617740..748f680da5e 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,57 +1,70 @@ """A entity class for mobile_app.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - ATTR_DEVICE_NAME, ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, - ATTR_SENSOR_NAME, + ATTR_SENSOR_STATE, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, - DOMAIN, SIGNAL_SENSOR_UPDATE, ) from .helpers import device_info -def sensor_id(webhook_id, unique_id): +def unique_id(webhook_id, sensor_unique_id): """Return a unique sensor ID.""" - return f"{webhook_id}_{unique_id}" + return f"{webhook_id}_{sensor_unique_id}" -class MobileAppEntity(Entity): +class MobileAppEntity(RestoreEntity): """Representation of an mobile app entity.""" def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry): - """Initialize the sensor.""" + """Initialize the entity.""" self._config = config self._device = device self._entry = entry self._registration = entry.data - self._sensor_id = sensor_id( - self._registration[CONF_WEBHOOK_ID], config[ATTR_SENSOR_UNIQUE_ID] - ) + self._unique_id = config[CONF_UNIQUE_ID] self._entity_type = config[ATTR_SENSOR_TYPE] self.unsub_dispatcher = None - self._name = f"{entry.data[ATTR_DEVICE_NAME]} {config[ATTR_SENSOR_NAME]}" + self._name = config[CONF_NAME] async def async_added_to_hass(self): """Register callbacks.""" self.unsub_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update ) + state = await self.async_get_last_state() + + if state is None: + return + + self.async_restore_last_state(state) async def async_will_remove_from_hass(self): """Disconnect dispatcher listener when removed.""" if self.unsub_dispatcher is not None: self.unsub_dispatcher() + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._config[ATTR_SENSOR_STATE] = last_state.state + self._config[ATTR_SENSOR_ATTRIBUTES] = { + **last_state.attributes, + **self._config[ATTR_SENSOR_ATTRIBUTES], + } + if ATTR_ICON in last_state.attributes: + self._config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] + @property def should_poll(self) -> bool: """Declare that this entity pushes its state to HA.""" @@ -80,27 +93,19 @@ class MobileAppEntity(Entity): @property def unique_id(self): """Return the unique ID of this sensor.""" - return self._sensor_id + return self._unique_id @property def device_info(self): """Return device registry information for this entity.""" return device_info(self._registration) - async def async_update(self): - """Get the latest state of the sensor.""" - data = self.hass.data[DOMAIN] - try: - self._config = data[self._entity_type][self._sensor_id] - except KeyError: - return - @callback def _handle_update(self, data): """Handle async event updates.""" - incoming_id = sensor_id(data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID]) - if incoming_id != self._sensor_id: + incoming_id = unique_id(data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID]) + if incoming_id != self._unique_id: return - self._config = data + self._config = {**self._config, **data} self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 7c5cbd135ed..a9079be4f04 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -25,9 +25,7 @@ from .const import ( ATTR_SUPPORTS_ENCRYPTION, CONF_SECRET, CONF_USER_ID, - DATA_BINARY_SENSOR, DATA_DELETED_IDS, - DATA_SENSOR, DOMAIN, ) @@ -138,9 +136,7 @@ def safe_registration(registration: Dict) -> Dict: def savable_state(hass: HomeAssistantType) -> Dict: """Return a clean object containing things that should be saved.""" return { - DATA_BINARY_SENSOR: hass.data[DOMAIN][DATA_BINARY_SENSOR], DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], - DATA_SENSOR: hass.data[DOMAIN][DATA_SENSOR], } diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 758df70c3d0..bd8ed771348 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -3,7 +3,7 @@ "name": "Mobile App", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mobile_app", - "requirements": ["PyNaCl==1.3.0", "emoji==0.5.4"], + "requirements": ["PyNaCl==1.3.0", "emoji==1.2.0"], "dependencies": ["http", "webhook", "person", "tag"], "after_dependencies": ["cloud", "camera", "notify"], "codeowners": ["@robbiet480"], diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 11e07ed5e79..b09ef86453b 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -1,19 +1,26 @@ """Sensor platform for mobile_app.""" from functools import partial -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + ATTR_DEVICE_NAME, + ATTR_SENSOR_ATTRIBUTES, + ATTR_SENSOR_DEVICE_CLASS, + ATTR_SENSOR_ICON, + ATTR_SENSOR_NAME, ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE, ATTR_SENSOR_TYPE_SENSOR as ENTITY_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SENSOR_UOM, DATA_DEVICES, DOMAIN, ) -from .entity import MobileAppEntity, sensor_id +from .entity import MobileAppEntity, unique_id async def async_setup_entry(hass, config_entry, async_add_entities): @@ -22,13 +29,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): webhook_id = config_entry.data[CONF_WEBHOOK_ID] - for config in hass.data[DOMAIN][ENTITY_TYPE].values(): - if config[CONF_WEBHOOK_ID] != webhook_id: + entity_registry = await er.async_get_registry(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 - - device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] - - entities.append(MobileAppSensor(config, device, config_entry)) + config = { + ATTR_SENSOR_ATTRIBUTES: {}, + ATTR_SENSOR_DEVICE_CLASS: entry.device_class, + ATTR_SENSOR_ICON: entry.original_icon, + ATTR_SENSOR_NAME: entry.original_name, + ATTR_SENSOR_STATE: None, + ATTR_SENSOR_TYPE: entry.domain, + ATTR_SENSOR_UNIQUE_ID: entry.unique_id, + ATTR_SENSOR_UOM: entry.unit_of_measurement, + } + entities.append(MobileAppSensor(config, entry.device_id, config_entry)) async_add_entities(entities) @@ -37,14 +53,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if data[CONF_WEBHOOK_ID] != webhook_id: return - unique_id = sensor_id(data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID]) - - entity = hass.data[DOMAIN][ENTITY_TYPE][unique_id] - - if "added" in entity: - return - - entity["added"] = True + data[CONF_UNIQUE_ID] = unique_id( + data[CONF_WEBHOOK_ID], data[ATTR_SENSOR_UNIQUE_ID] + ) + data[ + CONF_NAME + ] = f"{config_entry.data[ATTR_DEVICE_NAME]} {data[ATTR_SENSOR_NAME]}" device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] diff --git a/homeassistant/components/mobile_app/translations/et.json b/homeassistant/components/mobile_app/translations/et.json index 41d2be9d455..27f5fce2bdf 100644 --- a/homeassistant/components/mobile_app/translations/et.json +++ b/homeassistant/components/mobile_app/translations/et.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "install_app": "Home Assistantiga sidumiseks avage mobiilirakendus. \u00dchilduvate rakenduste loendi leiate jaotisest [dokumendid] ( {apps_url} )." + "install_app": "Home Assistantiga sidumiseks ava mobiilirakendus. \u00dchilduvate rakenduste loendi leiate jaotisest [dokumendid] ( {apps_url} )." }, "step": { "confirm": { diff --git a/homeassistant/components/mobile_app/translations/fr.json b/homeassistant/components/mobile_app/translations/fr.json index 09317e4a00d..f4b0f590e48 100644 --- a/homeassistant/components/mobile_app/translations/fr.json +++ b/homeassistant/components/mobile_app/translations/fr.json @@ -8,5 +8,10 @@ "description": "Voulez-vous configurer le composant Application mobile?" } } + }, + "device_automation": { + "action_type": { + "notify": "Envoyer une notification" + } } } \ No newline at end of file diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 043a555b6b7..3044f2df212 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -36,6 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + entity_registry as er, template, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -79,7 +80,6 @@ from .const import ( CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, - DATA_STORE, DOMAIN, ERR_ENCRYPTION_ALREADY_ENABLED, ERR_ENCRYPTION_NOT_AVAILABLE, @@ -95,7 +95,6 @@ from .helpers import ( error_response, registration_context, safe_registration, - savable_state, supports_encryption, webhook_response, ) @@ -415,7 +414,10 @@ 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}" - existing_sensor = unique_store_key in hass.data[DOMAIN][entity_type] + entity_registry = await er.async_get_registry(hass) + existing_sensor = entity_registry.async_get_entity_id( + entity_type, DOMAIN, unique_store_key + ) data[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] @@ -424,16 +426,7 @@ async def webhook_register_sensor(hass, config_entry, data): _LOGGER.debug( "Re-register for %s of existing sensor %s", device_name, unique_id ) - entry = hass.data[DOMAIN][entity_type][unique_store_key] - data = {**entry, **data} - hass.data[DOMAIN][entity_type][unique_store_key] = data - - hass.data[DOMAIN][DATA_STORE].async_delay_save( - lambda: savable_state(hass), DELAY_SAVE - ) - - if existing_sensor: async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, data) else: register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register" @@ -485,7 +478,10 @@ async def webhook_update_sensor_states(hass, config_entry, data): unique_store_key = f"{config_entry.data[CONF_WEBHOOK_ID]}_{unique_id}" - if unique_store_key not in hass.data[DOMAIN][entity_type]: + entity_registry = await er.async_get_registry(hass) + if not entity_registry.async_get_entity_id( + entity_type, DOMAIN, unique_store_key + ): _LOGGER.error( "Refusing to update %s non-registered sensor: %s", device_name, @@ -498,7 +494,7 @@ async def webhook_update_sensor_states(hass, config_entry, data): } continue - entry = hass.data[DOMAIN][entity_type][unique_store_key] + entry = {CONF_WEBHOOK_ID: config_entry.data[CONF_WEBHOOK_ID]} try: sensor = sensor_schema_full(sensor) @@ -518,16 +514,10 @@ async def webhook_update_sensor_states(hass, config_entry, data): new_state = {**entry, **sensor} - hass.data[DOMAIN][entity_type][unique_store_key] = new_state - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, new_state) resp[unique_id] = {"success": True} - hass.data[DOMAIN][DATA_STORE].async_delay_save( - lambda: savable_state(hass), DELAY_SAVE - ) - return webhook_response(resp, registration=config_entry.data) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 77e9b6f7ca9..428ddfadb14 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -1,9 +1,4 @@ """Support for Modbus.""" -import logging -import threading - -from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient -from pymodbus.transaction import ModbusRtuFramer import voluptuous as vol from homeassistant.components.cover import ( @@ -17,16 +12,15 @@ from homeassistant.const import ( CONF_HOST, CONF_METHOD, CONF_NAME, + CONF_OFFSET, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_STRUCTURE, CONF_TIMEOUT, CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform from .const import ( ATTR_ADDRESS, @@ -46,7 +40,6 @@ from .const import ( CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, - CONF_OFFSET, CONF_PARITY, CONF_PRECISION, CONF_REGISTER, @@ -71,11 +64,8 @@ from .const import ( DEFAULT_STRUCTURE_PREFIX, DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, - SERVICE_WRITE_COIL, - SERVICE_WRITE_REGISTER, ) - -_LOGGER = logging.getLogger(__name__) +from .modbus import modbus_setup BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) @@ -193,187 +183,6 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass, config): """Set up Modbus component.""" - hass.data[DOMAIN] = hub_collect = {} - - for conf_hub in config[DOMAIN]: - hub_collect[conf_hub[CONF_NAME]] = ModbusHub(conf_hub) - - # load platforms - for component, conf_key in ( - ("climate", CONF_CLIMATES), - ("cover", CONF_COVERS), - ): - if conf_key in conf_hub: - load_platform(hass, component, DOMAIN, conf_hub, config) - - def stop_modbus(event): - """Stop Modbus service.""" - for client in hub_collect.values(): - client.close() - - def write_register(service): - """Write Modbus registers.""" - unit = int(float(service.data[ATTR_UNIT])) - address = int(float(service.data[ATTR_ADDRESS])) - value = service.data[ATTR_VALUE] - client_name = service.data[ATTR_HUB] - if isinstance(value, list): - hub_collect[client_name].write_registers( - unit, address, [int(float(i)) for i in value] - ) - else: - hub_collect[client_name].write_register(unit, address, int(float(value))) - - def write_coil(service): - """Write Modbus coil.""" - unit = service.data[ATTR_UNIT] - address = service.data[ATTR_ADDRESS] - state = service.data[ATTR_STATE] - client_name = service.data[ATTR_HUB] - hub_collect[client_name].write_coil(unit, address, state) - - # do not wait for EVENT_HOMEASSISTANT_START, activate pymodbus now - for client in hub_collect.values(): - client.setup() - - # register function to gracefully stop modbus - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) - - # Register services for modbus - hass.services.register( - DOMAIN, - SERVICE_WRITE_REGISTER, - write_register, - schema=SERVICE_WRITE_REGISTER_SCHEMA, + return modbus_setup( + hass, config, SERVICE_WRITE_REGISTER_SCHEMA, SERVICE_WRITE_COIL_SCHEMA ) - hass.services.register( - DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA - ) - return True - - -class ModbusHub: - """Thread safe wrapper class for pymodbus.""" - - def __init__(self, client_config): - """Initialize the Modbus hub.""" - - # generic configuration - self._client = None - self._lock = threading.Lock() - self._config_name = client_config[CONF_NAME] - self._config_type = client_config[CONF_TYPE] - self._config_port = client_config[CONF_PORT] - self._config_timeout = client_config[CONF_TIMEOUT] - self._config_delay = 0 - - if self._config_type == "serial": - # serial configuration - self._config_method = client_config[CONF_METHOD] - self._config_baudrate = client_config[CONF_BAUDRATE] - self._config_stopbits = client_config[CONF_STOPBITS] - self._config_bytesize = client_config[CONF_BYTESIZE] - self._config_parity = client_config[CONF_PARITY] - else: - # network configuration - self._config_host = client_config[CONF_HOST] - self._config_delay = client_config[CONF_DELAY] - if self._config_delay > 0: - _LOGGER.warning( - "Parameter delay is accepted but not used in this version" - ) - - @property - def name(self): - """Return the name of this hub.""" - return self._config_name - - def setup(self): - """Set up pymodbus client.""" - if self._config_type == "serial": - self._client = ModbusSerialClient( - method=self._config_method, - port=self._config_port, - baudrate=self._config_baudrate, - stopbits=self._config_stopbits, - bytesize=self._config_bytesize, - parity=self._config_parity, - timeout=self._config_timeout, - retry_on_empty=True, - ) - elif self._config_type == "rtuovertcp": - self._client = ModbusTcpClient( - host=self._config_host, - port=self._config_port, - framer=ModbusRtuFramer, - timeout=self._config_timeout, - ) - elif self._config_type == "tcp": - self._client = ModbusTcpClient( - host=self._config_host, - port=self._config_port, - timeout=self._config_timeout, - ) - elif self._config_type == "udp": - self._client = ModbusUdpClient( - host=self._config_host, - port=self._config_port, - timeout=self._config_timeout, - ) - else: - assert False - - # Connect device - self.connect() - - def close(self): - """Disconnect client.""" - with self._lock: - self._client.close() - - def connect(self): - """Connect client.""" - with self._lock: - self._client.connect() - - def read_coils(self, unit, address, count): - """Read coils.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_coils(address, count, **kwargs) - - def read_discrete_inputs(self, unit, address, count): - """Read discrete inputs.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_discrete_inputs(address, count, **kwargs) - - def read_input_registers(self, unit, address, count): - """Read input registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_input_registers(address, count, **kwargs) - - def read_holding_registers(self, unit, address, count): - """Read holding registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_holding_registers(address, count, **kwargs) - - def write_coil(self, unit, address, value): - """Write coil.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_coil(address, value, **kwargs) - - def write_register(self, unit, address, value): - """Write register.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_register(address, value, **kwargs) - - def write_registers(self, unit, address, values): - """Write registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_registers(address, values, **kwargs) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index c9e9cc4196a..8e91945d073 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -10,13 +10,12 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE +from homeassistant.const import CONF_ADDRESS, CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE from homeassistant.helpers import config_validation as cv from .const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, - CONF_ADDRESS, CONF_COILS, CONF_HUB, CONF_INPUT_TYPE, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index b09a38f082e..45cfbf5eb57 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, + CONF_OFFSET, CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_STRUCTURE, @@ -28,7 +29,6 @@ from homeassistant.helpers.typing import ( HomeAssistantType, ) -from . import ModbusHub from .const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, @@ -39,7 +39,6 @@ from .const import ( CONF_DATA_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, - CONF_OFFSET, CONF_PRECISION, CONF_SCALE, CONF_STEP, @@ -49,6 +48,7 @@ from .const import ( DEFAULT_STRUCT_FORMAT, MODBUS_DOMAIN, ) +from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -146,7 +146,6 @@ class ModbusThermostat(ClimateEntity): False if entity pushes its state to HA. """ - # Handle polling directly in this entity return False diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e79b69bbb87..d3193cc004c 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -13,11 +13,10 @@ CONF_REVERSE_ORDER = "reverse_order" CONF_SCALE = "scale" CONF_COUNT = "count" CONF_PRECISION = "precision" -CONF_OFFSET = "offset" CONF_COILS = "coils" # integration names -DEFAULT_HUB = "default" +DEFAULT_HUB = "modbus_hub" MODBUS_DOMAIN = "modbus" # data types @@ -51,7 +50,6 @@ DEFAULT_SCAN_INTERVAL = 15 # seconds # binary_sensor.py CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" -CONF_ADDRESS = "address" # sensor.py # CONF_DATA_TYPE = "data_type" @@ -69,6 +67,7 @@ CONF_VERIFY_STATE = "verify_state" # climate.py CONF_CLIMATES = "climates" +CONF_CLIMATE = "climate" CONF_TARGET_TEMP = "target_temp_register" CONF_CURRENT_TEMP = "current_temp_register" CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" @@ -82,6 +81,7 @@ DEFAULT_STRUCTURE_PREFIX = ">f" DEFAULT_TEMP_UNIT = "C" # cover.py +CONF_COVER = "cover" CONF_STATE_OPEN = "state_open" CONF_STATE_CLOSED = "state_closed" CONF_STATE_OPENING = "state_opening" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index ab16e5306f1..09a465a2cdd 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -21,7 +21,6 @@ from homeassistant.helpers.typing import ( HomeAssistantType, ) -from . import ModbusHub from .const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, @@ -35,6 +34,7 @@ from .const import ( CONF_STATUS_REGISTER_TYPE, MODBUS_DOMAIN, ) +from .modbus import ModbusHub async def async_setup_platform( @@ -148,7 +148,6 @@ class ModbusCover(CoverEntity, RestoreEntity): False if entity pushes its state to HA. """ - # Handle polling directly in this entity return False diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py new file mode 100644 index 00000000000..21c6caa6fcc --- /dev/null +++ b/homeassistant/components/modbus/modbus.py @@ -0,0 +1,229 @@ +"""Support for Modbus.""" +import logging +import threading + +from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.transaction import ModbusRtuFramer + +from homeassistant.const import ( + ATTR_STATE, + CONF_COVERS, + CONF_DELAY, + CONF_HOST, + CONF_METHOD, + CONF_NAME, + CONF_PORT, + CONF_TIMEOUT, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers.discovery import load_platform + +from .const import ( + ATTR_ADDRESS, + ATTR_HUB, + ATTR_UNIT, + ATTR_VALUE, + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_CLIMATE, + CONF_CLIMATES, + CONF_COVER, + CONF_PARITY, + CONF_STOPBITS, + MODBUS_DOMAIN as DOMAIN, + SERVICE_WRITE_COIL, + SERVICE_WRITE_REGISTER, +) + +_LOGGER = logging.getLogger(__name__) + + +def modbus_setup( + hass, config, service_write_register_schema, service_write_coil_schema +): + """Set up Modbus component.""" + hass.data[DOMAIN] = hub_collect = {} + + for conf_hub in config[DOMAIN]: + hub_collect[conf_hub[CONF_NAME]] = ModbusHub(conf_hub) + + # modbus needs to be activated before components are loaded + # to avoid a racing problem + hub_collect[conf_hub[CONF_NAME]].setup() + + # load platforms + for component, conf_key in ( + (CONF_CLIMATE, CONF_CLIMATES), + (CONF_COVER, CONF_COVERS), + ): + if conf_key in conf_hub: + load_platform(hass, component, DOMAIN, conf_hub, config) + + def stop_modbus(event): + """Stop Modbus service.""" + for client in hub_collect.values(): + client.close() + + def write_register(service): + """Write Modbus registers.""" + unit = int(float(service.data[ATTR_UNIT])) + address = int(float(service.data[ATTR_ADDRESS])) + value = service.data[ATTR_VALUE] + client_name = service.data[ATTR_HUB] + if isinstance(value, list): + hub_collect[client_name].write_registers( + unit, address, [int(float(i)) for i in value] + ) + else: + hub_collect[client_name].write_register(unit, address, int(float(value))) + + def write_coil(service): + """Write Modbus coil.""" + unit = service.data[ATTR_UNIT] + address = service.data[ATTR_ADDRESS] + state = service.data[ATTR_STATE] + client_name = service.data[ATTR_HUB] + hub_collect[client_name].write_coil(unit, address, state) + + # register function to gracefully stop modbus + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) + + # Register services for modbus + hass.services.register( + DOMAIN, + SERVICE_WRITE_REGISTER, + write_register, + schema=service_write_register_schema, + ) + hass.services.register( + DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=service_write_coil_schema + ) + return True + + +class ModbusHub: + """Thread safe wrapper class for pymodbus.""" + + def __init__(self, client_config): + """Initialize the Modbus hub.""" + + # generic configuration + self._client = None + self._lock = threading.Lock() + self._config_name = client_config[CONF_NAME] + self._config_type = client_config[CONF_TYPE] + self._config_port = client_config[CONF_PORT] + self._config_timeout = client_config[CONF_TIMEOUT] + self._config_delay = 0 + + if self._config_type == "serial": + # serial configuration + self._config_method = client_config[CONF_METHOD] + self._config_baudrate = client_config[CONF_BAUDRATE] + self._config_stopbits = client_config[CONF_STOPBITS] + self._config_bytesize = client_config[CONF_BYTESIZE] + self._config_parity = client_config[CONF_PARITY] + else: + # network configuration + self._config_host = client_config[CONF_HOST] + self._config_delay = client_config[CONF_DELAY] + if self._config_delay > 0: + _LOGGER.warning( + "Parameter delay is accepted but not used in this version" + ) + + @property + def name(self): + """Return the name of this hub.""" + return self._config_name + + def setup(self): + """Set up pymodbus client.""" + if self._config_type == "serial": + self._client = ModbusSerialClient( + method=self._config_method, + port=self._config_port, + baudrate=self._config_baudrate, + stopbits=self._config_stopbits, + bytesize=self._config_bytesize, + parity=self._config_parity, + timeout=self._config_timeout, + retry_on_empty=True, + ) + elif self._config_type == "rtuovertcp": + self._client = ModbusTcpClient( + host=self._config_host, + port=self._config_port, + framer=ModbusRtuFramer, + timeout=self._config_timeout, + ) + elif self._config_type == "tcp": + self._client = ModbusTcpClient( + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, + ) + elif self._config_type == "udp": + self._client = ModbusUdpClient( + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, + ) + else: + assert False + + # Connect device + self.connect() + + def close(self): + """Disconnect client.""" + with self._lock: + self._client.close() + + def connect(self): + """Connect client.""" + with self._lock: + self._client.connect() + + def read_coils(self, unit, address, count): + """Read coils.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_coils(address, count, **kwargs) + + def read_discrete_inputs(self, unit, address, count): + """Read discrete inputs.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_discrete_inputs(address, count, **kwargs) + + def read_input_registers(self, unit, address, count): + """Read input registers.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_input_registers(address, count, **kwargs) + + def read_holding_registers(self, unit, address, count): + """Read holding registers.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_holding_registers(address, count, **kwargs) + + def write_coil(self, unit, address, value): + """Write coil.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_coil(address, value, **kwargs) + + def write_register(self, unit, address, value): + """Write register.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_register(address, value, **kwargs) + + def write_registers(self, unit, address, values): + """Write registers.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_registers(address, values, **kwargs) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 8fe1f886c3e..36fbef08428 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from . import ModbusHub from .const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, @@ -37,6 +36,7 @@ from .const import ( DEFAULT_HUB, MODBUS_DOMAIN, ) +from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -200,7 +200,6 @@ class ModbusRegisterSwitch(ModbusBaseSwitch, SwitchEntity): def turn_on(self, **kwargs): """Set switch on.""" - # Only holding register is writable if self._register_type == CALL_TYPE_REGISTER_HOLDING: self._write_register(self._command_on) @@ -209,7 +208,6 @@ class ModbusRegisterSwitch(ModbusBaseSwitch, SwitchEntity): def turn_off(self, **kwargs): """Set switch off.""" - # Only holding register is writable if self._register_type == CALL_TYPE_REGISTER_HOLDING: self._write_register(self._command_off) diff --git a/homeassistant/components/monoprice/translations/ko.json b/homeassistant/components/monoprice/translations/ko.json index 23e19173535..6afed3aa6d6 100644 --- a/homeassistant/components/monoprice/translations/ko.json +++ b/homeassistant/components/monoprice/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index e10f1655d2f..5d02d5a14a8 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -54,6 +54,7 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_motion_multicast) # Connect to motion gateway + multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) if not await connect_gateway_class.async_connect_gateway(host, key): raise ConfigEntryNotReady diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index cb85b45e0e0..d2bf216e26a 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,6 +1,4 @@ """Config flow to configure Motion Blinds using their WLAN API.""" -import logging - from motionblinds import MotionDiscovery import voluptuous as vol @@ -11,9 +9,6 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST from .const import DEFAULT_GATEWAY_NAME, DOMAIN from .gateway import ConnectMotionGateway -_LOGGER = logging.getLogger(__name__) - - CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_HOST): str, diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index dd637696e77..f8a673b3079 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,6 +1,4 @@ """Support for Motion Blinds sensors.""" -import logging - from motionblinds import BlindType from homeassistant.const import ( @@ -14,8 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY -_LOGGER = logging.getLogger(__name__) - ATTR_BATTERY_VOLTAGE = "battery_voltage" TYPE_BLIND = "blind" TYPE_GATEWAY = "gateway" diff --git a/homeassistant/components/motion_blinds/translations/fr.json b/homeassistant/components/motion_blinds/translations/fr.json index 86d008b9e6d..b6715970e40 100644 --- a/homeassistant/components/motion_blinds/translations/fr.json +++ b/homeassistant/components/motion_blinds/translations/fr.json @@ -1,16 +1,36 @@ { "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "connection_error": "\u00c9chec de la connexion " + }, + "error": { + "discovery_error": "Impossible de d\u00e9couvrir une Motion Gateway" + }, + "flow_title": "Stores de mouvement", "step": { "connect": { "data": { "api_key": "Cl\u00e9 API" }, - "description": "Vous aurez besoin de la cl\u00e9 API de 16 caract\u00e8res, voir https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key pour les instructions" + "description": "Vous aurez besoin de la cl\u00e9 API de 16 caract\u00e8res, voir https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key pour les instructions", + "title": "Stores de mouvement" }, "select": { "data": { "select_ip": "Adresse IP" - } + }, + "description": "Ex\u00e9cutez \u00e0 nouveau la configuration si vous souhaitez connecter des passerelles Motion suppl\u00e9mentaires", + "title": "S\u00e9lectionnez la Motion Gateway que vous souhaitez connecter" + }, + "user": { + "data": { + "api_key": "Clef d'API", + "host": "Adresse IP" + }, + "description": "Connectez-vous \u00e0 votre Motion Gateway, si l'adresse IP n'est pas d\u00e9finie, la d\u00e9tection automatique est utilis\u00e9e", + "title": "Stores de mouvement" } } } diff --git a/homeassistant/components/motion_blinds/translations/ko.json b/homeassistant/components/motion_blinds/translations/ko.json new file mode 100644 index 00000000000..9d2f0eead3d --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "connect": { + "data": { + "api_key": "API \ud0a4" + } + }, + "select": { + "data": { + "select_ip": "IP \uc8fc\uc18c" + } + }, + "user": { + "data": { + "api_key": "API \ud0a4", + "host": "IP \uc8fc\uc18c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/translations/nl.json b/homeassistant/components/motion_blinds/translations/nl.json new file mode 100644 index 00000000000..01cb117bb5b --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "connection_error": "Kan geen verbinding maken" + }, + "error": { + "discovery_error": "Kan geen Motion Gateway vinden" + }, + "step": { + "connect": { + "data": { + "api_key": "API-sleutel" + }, + "title": "Motion Blinds" + }, + "select": { + "data": { + "select_ip": "IP-adres" + }, + "title": "Selecteer de Motion Gateway waarmee u verbinding wilt maken" + }, + "user": { + "data": { + "api_key": "API-sleutel", + "host": "IP-adres" + } + } + } + } +} \ 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 1d34d22d65e..61e9d22c3cf 100644 --- a/homeassistant/components/motion_blinds/translations/pl.json +++ b/homeassistant/components/motion_blinds/translations/pl.json @@ -8,7 +8,7 @@ "error": { "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 bramki ruchu" }, - "flow_title": "Motion Blinds", + "flow_title": "Rolety Motion", "step": { "connect": { "data": { @@ -30,7 +30,7 @@ "host": "Adres IP" }, "description": "Po\u0142\u0105cz si\u0119 z bram\u0105 ruchu. Je\u015bli adres IP nie jest ustawiony, u\u017cywane jest automatyczne wykrywanie", - "title": "Motion Blinds" + "title": "Rolety Motion" } } } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 788f8d1957e..29f7e24da00 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components import websocket_api from homeassistant.const import ( CONF_CLIENT_ID, + CONF_DISCOVERY, CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, @@ -28,7 +29,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.const import CONF_UNIQUE_ID # noqa: F401 from homeassistant.core import CoreState, Event, HassJob, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template @@ -40,7 +40,6 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception # Loading the config flow file will register the flow -from . import config_flow # noqa: F401 pylint: disable=unused-import from . import debug_info, discovery from .const import ( ATTR_PAYLOAD, @@ -49,7 +48,6 @@ from .const import ( ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, - CONF_DISCOVERY, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -931,7 +929,9 @@ class MQTT: try: await asyncio.wait_for(self._pending_operations[mid].wait(), TIMEOUT_ACK) except asyncio.TimeoutError: - _LOGGER.error("Timed out waiting for mid %s", mid) + _LOGGER.warning( + "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid + ) finally: del self._pending_operations[mid] @@ -1054,7 +1054,6 @@ async def websocket_subscribe(hass, connection, msg): @callback def async_subscribe_connection_status(hass, connection_status_callback): """Subscribe to MQTT connection changes.""" - connection_status_callback_job = HassJob(connection_status_callback) async def connected(): diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 4b209f6f364..8868d487f93 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -136,6 +136,7 @@ ABBREVIATIONS = { "set_pos_tpl": "set_position_template", "set_pos_t": "set_position_topic", "pos_t": "position_topic", + "pos_tpl": "position_template", "spd_cmd_t": "speed_command_topic", "spd_stat_t": "speed_state_topic", "spd_val_tpl": "speed_value_template", @@ -147,6 +148,7 @@ ABBREVIATIONS = { "stat_on": "state_on", "stat_open": "state_open", "stat_opening": "state_opening", + "stat_stopped": "state_stopped", "stat_locked": "state_locked", "stat_unlocked": "state_unlocked", "stat_t": "state_topic", @@ -173,6 +175,7 @@ ABBREVIATIONS = { "temp_unit": "temperature_unit", "tilt_clsd_val": "tilt_closed_value", "tilt_cmd_t": "tilt_command_topic", + "tilt_cmd_tpl": "tilt_command_template", "tilt_inv_stat": "tilt_invert_state", "tilt_max": "tilt_max", "tilt_min": "tilt_min", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 38fec57607e..2f2ba06d6d7 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -110,7 +110,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT alarm control panel dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index d965401cef4..b9fb297cd5c 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -78,7 +78,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT binary sensor dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) @@ -214,7 +213,6 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity): @callback def _value_is_expired(self, *_): """Triggered when value is expired.""" - self._expiration_trigger = None self._expired = True diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 21fcb9276dd..cc58741a923 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,6 +1,5 @@ """Camera that loads a picture from an MQTT topic.""" import functools -import logging import voluptuous as vol @@ -23,8 +22,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - CONF_TOPIC = "topic" DEFAULT_NAME = "MQTT Camera" @@ -52,7 +49,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT camera dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 15c7c916eeb..ede39103791 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -38,6 +38,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -98,8 +100,6 @@ CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" -CONF_PAYLOAD_OFF = "payload_off" -CONF_PAYLOAD_ON = "payload_on" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" @@ -274,7 +274,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT climate device dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 5c4016437a6..e19aaecc3db 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1,12 +1,12 @@ """Config flow for MQTT.""" from collections import OrderedDict -import logging import queue import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( + CONF_DISCOVERY, CONF_HOST, CONF_PASSWORD, CONF_PAYLOAD, @@ -22,7 +22,6 @@ from .const import ( ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, - CONF_DISCOVERY, CONF_WILL_MESSAGE, DATA_MQTT_CONFIG, DEFAULT_BIRTH, @@ -31,8 +30,6 @@ from .const import ( ) from .util import MQTT_WILL_BIRTH_SCHEMA -_LOGGER = logging.getLogger(__name__) - @config_entries.HANDLERS.register("mqtt") class FlowHandler(config_entries.ConfigFlow): diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 3e56ab6caf9..6c334eca311 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -11,7 +11,6 @@ ATTR_TOPIC = "topic" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" -CONF_DISCOVERY = "discovery" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_STATE_TOPIC = "state_topic" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index dc2cba0efab..7b7f983b1e4 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -59,9 +59,11 @@ from .mixins import ( _LOGGER = logging.getLogger(__name__) CONF_GET_POSITION_TOPIC = "position_topic" -CONF_SET_POSITION_TEMPLATE = "set_position_template" +CONF_GET_POSITION_TEMPLATE = "position_template" CONF_SET_POSITION_TOPIC = "set_position_topic" +CONF_SET_POSITION_TEMPLATE = "set_position_template" CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" +CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" @@ -74,6 +76,7 @@ CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_STATE_STOPPED = "state_stopped" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_INVERT_STATE = "tilt_invert_state" CONF_TILT_MAX = "tilt_max" @@ -92,6 +95,7 @@ DEFAULT_PAYLOAD_STOP = "STOP" DEFAULT_POSITION_CLOSED = 0 DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False +DEFAULT_STATE_STOPPED = "stopped" DEFAULT_TILT_CLOSED_POSITION = 0 DEFAULT_TILT_INVERT_STATE = False DEFAULT_TILT_MAX = 100 @@ -115,8 +119,27 @@ def validate_options(value): """ if CONF_SET_POSITION_TOPIC in value and CONF_GET_POSITION_TOPIC not in value: raise vol.Invalid( - "set_position_topic must be set together with position_topic." + "'set_position_topic' must be set together with 'position_topic'." ) + + if ( + CONF_GET_POSITION_TOPIC in value + and CONF_STATE_TOPIC not in value + and CONF_VALUE_TEMPLATE in value + ): + _LOGGER.warning( + "using 'value_template' for 'position_topic' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please replace it with 'position_template'" + ) + + if CONF_TILT_INVERT_STATE in value: + _LOGGER.warning( + "'tilt_invert_state' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please invert tilt using 'tilt_min' & 'tilt_max'" + ) + return value @@ -143,14 +166,13 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_STOPPED, default=DEFAULT_STATE_STOPPED): cv.string, vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional( CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION ): int, vol.Optional(CONF_TILT_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional( - CONF_TILT_INVERT_STATE, default=DEFAULT_TILT_INVERT_STATE - ): cv.boolean, + vol.Optional(CONF_TILT_INVERT_STATE): cv.boolean, vol.Optional(CONF_TILT_MAX, default=DEFAULT_TILT_MAX): int, vol.Optional(CONF_TILT_MIN, default=DEFAULT_TILT_MIN): int, vol.Optional( @@ -163,6 +185,8 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template, } ) .extend(MQTT_AVAILABILITY_SCHEMA.schema) @@ -181,7 +205,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT cover dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) @@ -222,12 +245,22 @@ class MqttCover(MqttEntity, CoverEntity): ) self._tilt_optimistic = config[CONF_TILT_STATE_OPTIMISTIC] - template = self._config.get(CONF_VALUE_TEMPLATE) - if template is not None: - template.hass = self.hass + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = self.hass + set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) if set_position_template is not None: set_position_template.hass = self.hass + + get_position_template = self._config.get(CONF_GET_POSITION_TEMPLATE) + if get_position_template is not None: + get_position_template.hass = self.hass + + set_tilt_template = self._config.get(CONF_TILT_COMMAND_TEMPLATE) + if set_tilt_template is not None: + set_tilt_template.hass = self.hass + tilt_status_template = self._config.get(CONF_TILT_STATUS_TEMPLATE) if tilt_status_template is not None: tilt_status_template.hass = self.hass @@ -262,21 +295,32 @@ class MqttCover(MqttEntity, CoverEntity): def state_message_received(msg): """Handle new MQTT state messages.""" payload = msg.payload - template = self._config.get(CONF_VALUE_TEMPLATE) - if template is not None: - payload = template.async_render_with_possible_json_value(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) - if payload == self._config[CONF_STATE_OPEN]: - self._state = STATE_OPEN + if payload == self._config[CONF_STATE_STOPPED]: + if self._config.get(CONF_GET_POSITION_TOPIC) is not None: + self._state = ( + STATE_CLOSED + if self._position == DEFAULT_POSITION_CLOSED + else STATE_OPEN + ) + else: + self._state = ( + STATE_CLOSED if self._state == STATE_CLOSING else STATE_OPEN + ) elif payload == self._config[CONF_STATE_OPENING]: self._state = STATE_OPENING - elif payload == self._config[CONF_STATE_CLOSED]: - self._state = STATE_CLOSED elif payload == self._config[CONF_STATE_CLOSING]: self._state = STATE_CLOSING + elif payload == self._config[CONF_STATE_OPEN]: + self._state = STATE_OPEN + elif payload == self._config[CONF_STATE_CLOSED]: + self._state = STATE_CLOSED else: _LOGGER.warning( - "Payload is not supported (e.g. open, closed, opening, closing): %s", + "Payload is not supported (e.g. open, closed, opening, closing, stopped): %s", payload, ) return @@ -286,9 +330,16 @@ class MqttCover(MqttEntity, CoverEntity): @callback @log_messages(self.hass, self.entity_id) def position_message_received(msg): - """Handle new MQTT state messages.""" + """Handle new MQTT position messages.""" payload = msg.payload - template = self._config.get(CONF_VALUE_TEMPLATE) + + template = self._config.get(CONF_GET_POSITION_TEMPLATE) + + # To be removed in 2021.6: + # allow using `value_template` as position template if no `state_topic` + if template is None and self._config.get(CONF_STATE_TOPIC) is None: + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: payload = template.async_render_with_possible_json_value(payload) @@ -297,13 +348,14 @@ class MqttCover(MqttEntity, CoverEntity): float(payload), COVER_PAYLOAD ) self._position = percentage_payload - self._state = ( - STATE_CLOSED - if percentage_payload == DEFAULT_POSITION_CLOSED - else STATE_OPEN - ) + if self._config.get(CONF_STATE_TOPIC) is None: + self._state = ( + STATE_CLOSED + if percentage_payload == DEFAULT_POSITION_CLOSED + else STATE_OPEN + ) else: - _LOGGER.warning("Payload is not integer within range: %s", payload) + _LOGGER.warning("Payload '%s' is not numeric", payload) return self.async_write_ha_state() @@ -313,13 +365,18 @@ class MqttCover(MqttEntity, CoverEntity): "msg_callback": position_message_received, "qos": self._config[CONF_QOS], } - elif self._config.get(CONF_STATE_TOPIC): + + if self._config.get(CONF_STATE_TOPIC): topics["state_topic"] = { "topic": self._config.get(CONF_STATE_TOPIC), "msg_callback": state_message_received, "qos": self._config[CONF_QOS], } - else: + + if ( + self._config.get(CONF_GET_POSITION_TOPIC) is None + and self._config.get(CONF_STATE_TOPIC) is None + ): # Force into optimistic mode. self._optimistic = True @@ -488,28 +545,32 @@ class MqttCover(MqttEntity, CoverEntity): async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" - position = kwargs[ATTR_TILT_POSITION] - - # The position needs to be between min and max - level = self.find_in_range_from_percent(position) + set_tilt_template = self._config.get(CONF_TILT_COMMAND_TEMPLATE) + tilt = kwargs[ATTR_TILT_POSITION] + percentage_tilt = tilt + tilt = self.find_in_range_from_percent(tilt) + if set_tilt_template is not None: + tilt = set_tilt_template.async_render(parse_result=False, **kwargs) mqtt.async_publish( self.hass, self._config.get(CONF_TILT_COMMAND_TOPIC), - level, + tilt, self._config[CONF_QOS], self._config[CONF_RETAIN], ) + if self._tilt_optimistic: + self._tilt_value = percentage_tilt + self.async_write_ha_state() async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) position = kwargs[ATTR_POSITION] percentage_position = position + position = self.find_in_range_from_percent(position, COVER_PAYLOAD) if set_position_template is not None: position = set_position_template.async_render(parse_result=False, **kwargs) - else: - position = self.find_in_range_from_percent(position, COVER_PAYLOAD) mqtt.async_publish( self.hass, @@ -557,7 +618,7 @@ class MqttCover(MqttEntity, CoverEntity): max_percent = 100 min_percent = 0 position_percentage = min(max(position_percentage, min_percent), max_percent) - if range_type == TILT_PAYLOAD and self._config[CONF_TILT_INVERT_STATE]: + if range_type == TILT_PAYLOAD and self._config.get(CONF_TILT_INVERT_STATE): return 100 - position_percentage return position_percentage @@ -581,6 +642,6 @@ class MqttCover(MqttEntity, CoverEntity): position = round(current_range * (percentage / 100.0)) position += offset - if range_type == TILT_PAYLOAD and self._config[CONF_TILT_INVERT_STATE]: + if range_type == TILT_PAYLOAD and self._config.get(CONF_TILT_INVERT_STATE): position = max_range - position + offset return position diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index a3c56652253..52aeb20e3aa 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -1,7 +1,6 @@ """Helper to handle a set of topics to subscribe to.""" from collections import deque from functools import wraps -import logging from typing import Any from homeassistant.helpers.typing import HomeAssistantType @@ -9,8 +8,6 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC from .models import MessageCallbackType -_LOGGER = logging.getLogger(__name__) - DATA_MQTT_DEBUG_INFO = "mqtt_debug_info" STORED_MESSAGES = 10 diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index d3e1f33421d..50d6a6e4d19 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -1,6 +1,5 @@ """Provides device automations for MQTT.""" import functools -import logging import voluptuous as vol @@ -10,8 +9,6 @@ from . import device_trigger from .. import mqtt from .mixins import async_setup_entry_helper -_LOGGER = logging.getLogger(__name__) - AUTOMATION_TYPE_TRIGGER = "trigger" AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index 8b51b9fac0e..bd5d9a1e60e 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -1,6 +1,5 @@ """Support for tracking MQTT enabled devices identified through discovery.""" import functools -import logging import voluptuous as vol @@ -34,8 +33,6 @@ from ..mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" @@ -59,7 +56,6 @@ PLATFORM_SCHEMA_DISCOVERY = ( async def async_setup_entry_from_discovery(hass, config_entry, async_add_entities): """Set up MQTT device tracker dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 6a04fd48049..d6e2ee0fc65 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -66,10 +67,11 @@ TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_AUTOMATION_TYPE): str, vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, - vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string), - vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_SUBTYPE): cv.string, + vol.Required(CONF_TOPIC): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE, default=None): vol.Any(None, cv.string), }, validate_device_has_at_least_one_identifier, ) @@ -89,12 +91,16 @@ class TriggerInstance: async def async_attach_trigger(self): """Attach MQTT trigger.""" mqtt_config = { + mqtt_trigger.CONF_PLATFORM: mqtt.DOMAIN, mqtt_trigger.CONF_TOPIC: self.trigger.topic, mqtt_trigger.CONF_ENCODING: DEFAULT_ENCODING, mqtt_trigger.CONF_QOS: self.trigger.qos, } if self.trigger.payload: mqtt_config[CONF_PAYLOAD] = self.trigger.payload + if self.trigger.value_template: + mqtt_config[CONF_VALUE_TEMPLATE] = self.trigger.value_template + mqtt_config = mqtt_trigger.TRIGGER_SCHEMA(mqtt_config) if self.remove: self.remove() @@ -119,6 +125,7 @@ class Trigger: subtype: str = attr.ib() topic: str = attr.ib() type: str = attr.ib() + value_template: str = attr.ib() trigger_instances: List[TriggerInstance] = attr.ib(factory=list) async def add_trigger(self, action, automation_info): @@ -151,6 +158,7 @@ class Trigger: self.qos = config[CONF_QOS] topic_changed = self.topic != config[CONF_TOPIC] self.topic = config[CONF_TOPIC] + self.value_template = config[CONF_VALUE_TEMPLATE] # Unsubscribe+subscribe if this trigger is in use and topic has changed # If topic is same unsubscribe+subscribe will execute in the wrong order @@ -243,6 +251,7 @@ async def async_setup_trigger(hass, config, config_entry, discovery_data): payload=config[CONF_PAYLOAD], qos=config[CONF_QOS], remove_signal=remove_signal, + value_template=config[CONF_VALUE_TEMPLATE], ) else: await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger( @@ -323,6 +332,7 @@ async def async_attach_trigger( topic=None, payload=None, qos=None, + value_template=None, ) return await hass.data[DEVICE_TRIGGERS][discovery_id].add_trigger( action, automation_info diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e5cebd43714..24d652062ae 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -1,6 +1,5 @@ """Support for MQTT fans.""" import functools -import logging import voluptuous as vol @@ -48,8 +47,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - CONF_STATE_VALUE_TEMPLATE = "state_value_template" CONF_SPEED_STATE_TOPIC = "speed_state_topic" CONF_SPEED_COMMAND_TOPIC = "speed_command_topic" @@ -122,7 +119,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT fan dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) @@ -317,7 +313,20 @@ class MqttFan(MqttEntity, FanEntity): """Return the oscillation state.""" return self._oscillation - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the entity. This method is a coroutine. diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index e780332d093..412302273ac 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,6 +1,5 @@ """Support for MQTT lights.""" import functools -import logging import voluptuous as vol @@ -15,8 +14,6 @@ from .schema_basic import PLATFORM_SCHEMA_BASIC, async_setup_entity_basic from .schema_json import PLATFORM_SCHEMA_JSON, async_setup_entity_json from .schema_template import PLATFORM_SCHEMA_TEMPLATE, async_setup_entity_template -_LOGGER = logging.getLogger(__name__) - def validate_mqtt_light(value): """Validate MQTT light schema.""" @@ -43,7 +40,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT light dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 489b424f4eb..8ec5c29db62 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_COLOR_TEMP, CONF_DEVICE, CONF_EFFECT, + CONF_HS, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, @@ -75,7 +76,6 @@ CONF_EFFECT_LIST = "effect_list" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" -CONF_HS = "hs" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index e696e99552e..665e1a30d99 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, + CONF_STATE_TEMPLATE, CONF_UNIQUE_ID, STATE_OFF, STATE_ON, @@ -62,7 +63,6 @@ CONF_GREEN_TEMPLATE = "green_template" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" CONF_RED_TEMPLATE = "red_template" -CONF_STATE_TEMPLATE = "state_template" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" PLATFORM_SCHEMA_TEMPLATE = ( @@ -383,7 +383,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): values["flash"] = kwargs.get(ATTR_FLASH) if ATTR_TRANSITION in kwargs: - values["transition"] = int(kwargs[ATTR_TRANSITION]) + values["transition"] = kwargs[ATTR_TRANSITION] mqtt.async_publish( self.hass, @@ -408,7 +408,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): self._state = False if ATTR_TRANSITION in kwargs: - values["transition"] = int(kwargs[ATTR_TRANSITION]) + values["transition"] = kwargs[ATTR_TRANSITION] mqtt.async_publish( self.hass, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index b08f8f8bb43..e66b93f51c0 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -1,6 +1,5 @@ """Support for MQTT locks.""" import functools -import logging import voluptuous as vol @@ -37,8 +36,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_UNLOCK = "payload_unlock" @@ -84,7 +81,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT lock dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 4d44090a4e3..9de3b071844 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/mqtt", "requirements": ["paho-mqtt==1.5.1"], "dependencies": ["http"], - "codeowners": ["@home-assistant/core", "@emontnemery"] + "codeowners": ["@emontnemery"] } diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 1ab2054b355..8d9c9533ed3 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -387,7 +387,7 @@ class MqttDiscoveryUpdate(Entity): entity_registry.async_remove(self.entity_id) await cleanup_device_registry(self.hass, entity_entry.device_id) else: - await self.async_remove() + await self.async_remove(force_remove=True) async def discovery_callback(payload): """Handle discovery update.""" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 159f466f7ae..aa24f81eb69 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -28,6 +28,7 @@ from . import ( subscription, ) from .. import mqtt +from .const import CONF_RETAIN from .debug_info import log_messages from .mixins import ( MQTT_AVAILABILITY_SCHEMA, @@ -67,7 +68,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT number dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, async_add_entities, config_entry=config_entry ) @@ -110,7 +110,6 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def message_received(msg): """Handle new MQTT messages.""" - try: if msg.payload.decode("utf-8").isnumeric(): self._current_number = int(msg.payload) @@ -149,7 +148,6 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): async def async_set_value(self, value: float) -> None: """Update the current value.""" - current_number = value if value.is_integer(): @@ -164,6 +162,7 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): self._config[CONF_COMMAND_TOPIC], current_number, self._config[CONF_QOS], + self._config[CONF_RETAIN], ) @property diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 908f4bafd30..c6d9140af61 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -1,6 +1,5 @@ """Support for MQTT scenes.""" import functools -import logging import voluptuous as vol @@ -20,8 +19,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = "MQTT Scene" DEFAULT_RETAIN = False @@ -47,7 +44,6 @@ async def async_setup_platform( 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 ) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3f79ab1bafe..d017cb2ce85 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -1,7 +1,6 @@ """Support for MQTT sensors.""" from datetime import timedelta import functools -import logging from typing import Optional import voluptuous as vol @@ -38,8 +37,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - CONF_EXPIRE_AFTER = "expire_after" DEFAULT_NAME = "MQTT Sensor" @@ -72,7 +69,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT sensors dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 04dce23f5de..21d3915628a 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -1,40 +1,79 @@ # Describes the format for available MQTT services publish: + name: Publish description: Publish a message to an MQTT topic. fields: topic: + name: Topic description: Topic to publish payload. + required: true example: /homeassistant/hello + selector: + text: payload: + name: Payload description: Payload to publish. example: This is great + selector: + text: payload_template: - description: Template to render as payload value. Ignored if payload given. + name: Payload Template + description: + Template to render as payload value. Ignored if payload given. + advanced: true example: "{{ states('sensor.temperature') }}" + selector: + object: qos: + name: QoS description: Quality of Service to use. + advanced: true example: 2 values: - 0 - 1 - 2 default: 0 + selector: + select: + options: + - "0" + - "1" + - "2" retain: + name: Retain description: If message should have the retain flag set. - example: true default: false + example: true + selector: + boolean: dump: - description: Dump messages on a topic selector to the 'mqtt_dump.txt' file in your config folder. + name: Dump + description: + Dump messages on a topic selector to the 'mqtt_dump.txt' file in your + configuration folder. fields: topic: + name: Topic description: topic to listen to example: "OpenZWave/#" + selector: + text: duration: + name: Duration description: how long we should listen for messages in seconds example: 5 default: 5 + selector: + number: + min: 1 + max: 300 + step: 1 + unit_of_measurement: "seconds" + mode: slider reload: - description: Reload all mqtt entities from yaml. + name: Reload + description: Reload all MQTT entities from YAML. diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index c61c30c922e..5c2efabc266 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -1,5 +1,4 @@ """Helper to handle a set of topics to subscribe to.""" -import logging from typing import Any, Callable, Dict, Optional import attr @@ -12,8 +11,6 @@ from .. import mqtt from .const import DEFAULT_QOS from .models import MessageCallbackType -_LOGGER = logging.getLogger(__name__) - @attr.s(slots=True) class EntitySubscription: diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index d6d476b680d..939d6bb98b1 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -1,6 +1,5 @@ """Support for MQTT switches.""" import functools -import logging import voluptuous as vol @@ -42,8 +41,6 @@ from .mixins import ( async_setup_entry_helper, ) -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OFF = "OFF" @@ -80,7 +77,6 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT switch dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index b691c5cf8ce..4960ff50fb5 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -45,7 +45,6 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( async def async_setup_entry(hass, config_entry): """Set up MQTT tag scan dynamically through MQTT discovery.""" - setup = functools.partial(async_setup_tag, hass, config_entry=config_entry) await async_setup_entry_helper(hass, "tag", setup, PLATFORM_SCHEMA) diff --git a/homeassistant/components/mqtt/translations/et.json b/homeassistant/components/mqtt/translations/et.json index 53d6d391e8f..d2b863cab46 100644 --- a/homeassistant/components/mqtt/translations/et.json +++ b/homeassistant/components/mqtt/translations/et.json @@ -78,7 +78,7 @@ "will_retain": "L\u00f5petamisteate j\u00e4\u00e4dvustamine", "will_topic": "L\u00f5petamisteade" }, - "description": "Valige MQTT s\u00e4tted." + "description": "Vali MQTT s\u00e4tted." } } } diff --git a/homeassistant/components/mqtt/translations/ko.json b/homeassistant/components/mqtt/translations/ko.json index f713d564438..fd79863fadf 100644 --- a/homeassistant/components/mqtt/translations/ko.json +++ b/homeassistant/components/mqtt/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { - "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "broker": { @@ -52,7 +52,7 @@ "error": { "bad_birth": "Birth \ud1a0\ud53d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "bad_will": "Will \ud1a0\ud53d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "broker": { @@ -71,6 +71,7 @@ "birth_retain": "Birth \uba54\uc2dc\uc9c0 \ub9ac\ud14c\uc778", "birth_topic": "Birth \uba54\uc2dc\uc9c0 \ud1a0\ud53d", "discovery": "\uc7a5\uce58 \uac80\uc0c9 \ud65c\uc131\ud654", + "will_enable": "Will \uba54\uc2dc\uc9c0 \ud65c\uc131\ud654", "will_payload": "Will \uba54\uc2dc\uc9c0 \ud398\uc774\ub85c\ub4dc", "will_qos": "Will \uba54\uc2dc\uc9c0 QoS", "will_retain": "Will \uba54\uc2dc\uc9c0 \ub9ac\ud14c\uc778", diff --git a/homeassistant/components/mqtt/translations/nl.json b/homeassistant/components/mqtt/translations/nl.json index a0ab0e497da..3b3ebf9fe3b 100644 --- a/homeassistant/components/mqtt/translations/nl.json +++ b/homeassistant/components/mqtt/translations/nl.json @@ -63,6 +63,7 @@ }, "options": { "data": { + "birth_enable": "Geboortebericht inschakelen", "birth_payload": "Birth message payload", "birth_topic": "Birth message onderwerp" } diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 1c96b3de266..82f7885b85d 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -1,11 +1,12 @@ """Offer MQTT listening automation rules.""" import json +import logging import voluptuous as vol -from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM +from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import HassJob, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from .. import mqtt @@ -20,8 +21,9 @@ DEFAULT_QOS = 0 TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): mqtt.DOMAIN, - vol.Required(CONF_TOPIC): mqtt.util.valid_subscribe_topic, - vol.Optional(CONF_PAYLOAD): cv.string, + vol.Required(CONF_TOPIC): mqtt.util.valid_subscribe_topic_template, + vol.Optional(CONF_PAYLOAD): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) @@ -29,19 +31,46 @@ TRIGGER_SCHEMA = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" topic = config[CONF_TOPIC] - payload = config.get(CONF_PAYLOAD) + wanted_payload = config.get(CONF_PAYLOAD) + value_template = config.get(CONF_VALUE_TEMPLATE) encoding = config[CONF_ENCODING] or None qos = config[CONF_QOS] job = HassJob(action) + variables = None + if automation_info: + variables = automation_info.get("variables") + + template.attach(hass, wanted_payload) + if wanted_payload: + wanted_payload = wanted_payload.async_render( + variables, limited=True, parse_result=False + ) + + template.attach(hass, topic) + if isinstance(topic, template.Template): + topic = topic.async_render(variables, limited=True, parse_result=False) + topic = mqtt.util.valid_subscribe_topic(topic) + + template.attach(hass, value_template) @callback def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" - if payload is None or payload == mqttmsg.payload: + payload = mqttmsg.payload + + if value_template is not None: + payload = value_template.async_render_with_possible_json_value( + payload, + error_value=None, + ) + + if wanted_payload is None or wanted_payload == payload: data = { "platform": "mqtt", "topic": mqttmsg.topic, @@ -57,6 +86,10 @@ async def async_attach_trigger(hass, config, action, automation_info): hass.async_run_hass_job(job, {"trigger": data}) + _LOGGER.debug( + "Attaching MQTT trigger for topic: '%s', payload: '%s'", topic, wanted_payload + ) + remove = await mqtt.async_subscribe( hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos ) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 651fe48fe3d..b8fca50a153 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_PAYLOAD -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from .const import ( ATTR_PAYLOAD, @@ -61,6 +61,16 @@ def valid_subscribe_topic(value: Any) -> str: return value +def valid_subscribe_topic_template(value: Any) -> template.Template: + """Validate either a jinja2 template or a valid MQTT subscription topic.""" + tpl = template.Template(value) + + if tpl.is_static: + valid_subscribe_topic(value) + + return tpl + + def valid_publish_topic(value: Any) -> str: """Validate that we can publish using this MQTT topic.""" value = valid_topic(value) diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index e580e874993..85fd1247381 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -1,6 +1,5 @@ """Support for MQTT vacuums.""" import functools -import logging import voluptuous as vol @@ -13,8 +12,6 @@ from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import PLATFORM_SCHEMA_LEGACY, async_setup_entity_legacy from .schema_state import PLATFORM_SCHEMA_STATE, async_setup_entity_state -_LOGGER = logging.getLogger(__name__) - def validate_mqtt_vacuum(value): """Validate MQTT vacuum schema.""" @@ -35,7 +32,6 @@ 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 MQTT vacuum dynamically through MQTT discovery.""" - setup = functools.partial( _async_setup_entity, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index e7be64be6ae..ca91b8948d4 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -1,6 +1,5 @@ """Support for Legacy MQTT vacuum.""" import json -import logging import voluptuous as vol @@ -39,8 +38,6 @@ from ..mixins import ( ) from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services -_LOGGER = logging.getLogger(__name__) - SERVICE_TO_STRING = { SUPPORT_TURN_ON: "turn_on", SUPPORT_TURN_OFF: "turn_off", @@ -377,7 +374,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): No need to check SUPPORT_BATTERY, this won't be called if battery_level is None. """ - return icon_for_battery_level( battery_level=self.battery_level, charging=self._charging ) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index c754ba1604a..3e43736ab2e 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -1,6 +1,5 @@ """Support for a State MQTT vacuum.""" import json -import logging import voluptuous as vol @@ -43,8 +42,6 @@ from ..mixins import ( ) from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services -_LOGGER = logging.getLogger(__name__) - SERVICE_TO_STRING = { SUPPORT_START: "start", SUPPORT_PAUSE: "pause", diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py new file mode 100644 index 00000000000..20a7092d58a --- /dev/null +++ b/homeassistant/components/mullvad/__init__.py @@ -0,0 +1,68 @@ +"""The Mullvad VPN integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from mullvad_api import MullvadAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +PLATFORMS = ["binary_sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Mullvad VPN integration.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: dict): + """Set up Mullvad VPN integration.""" + + async def async_get_mullvad_api_data(): + with async_timeout.timeout(10): + api = await hass.async_add_executor_job(MullvadAPI) + return api.data + + coordinator = update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_mullvad_api_data, + update_interval=timedelta(minutes=1), + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + del hass.data[DOMAIN] + + return unload_ok diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py new file mode 100644 index 00000000000..f85820cd7d0 --- /dev/null +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -0,0 +1,52 @@ +"""Setup Mullvad VPN Binary Sensors.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +BINARY_SENSORS = ( + { + CONF_ID: "mullvad_exit_ip", + CONF_NAME: "Mullvad Exit IP", + CONF_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, + }, +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = hass.data[DOMAIN] + + async_add_entities( + MullvadBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS + ) + + +class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Represents a Mullvad binary sensor.""" + + def __init__(self, coordinator, sensor): # pylint: disable=super-init-not-called + """Initialize the Mullvad binary sensor.""" + super().__init__(coordinator) + self.id = sensor[CONF_ID] + self._name = sensor[CONF_NAME] + self._device_class = sensor[CONF_DEVICE_CLASS] + + @property + def device_class(self): + """Return the device class for this binary sensor.""" + return self._device_class + + @property + def name(self): + """Return the name for this binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state for this binary sensor.""" + return self.coordinator.data[self.id] diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py new file mode 100644 index 00000000000..674308c1d1a --- /dev/null +++ b/homeassistant/components/mullvad/config_flow.py @@ -0,0 +1,35 @@ +"""Config flow for Mullvad VPN integration.""" +import logging + +from mullvad_api import MullvadAPI, MullvadAPIError + +from homeassistant import config_entries + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Mullvad VPN.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_configured") + + errors = {} + if user_input is not None: + try: + await self.hass.async_add_executor_job(MullvadAPI) + except MullvadAPIError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Mullvad VPN", data=user_input) + + return self.async_show_form(step_id="user", errors=errors) diff --git a/homeassistant/components/mullvad/const.py b/homeassistant/components/mullvad/const.py new file mode 100644 index 00000000000..4e3be28782c --- /dev/null +++ b/homeassistant/components/mullvad/const.py @@ -0,0 +1,3 @@ +"""Constants for the Mullvad VPN integration.""" + +DOMAIN = "mullvad" diff --git a/homeassistant/components/mullvad/manifest.json b/homeassistant/components/mullvad/manifest.json new file mode 100644 index 00000000000..1a440240d7e --- /dev/null +++ b/homeassistant/components/mullvad/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mullvad", + "name": "Mullvad VPN", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mullvad", + "requirements": [ + "mullvad-api==1.0.0" + ], + "codeowners": [ + "@meichthys" + ] +} diff --git a/homeassistant/components/mullvad/strings.json b/homeassistant/components/mullvad/strings.json new file mode 100644 index 00000000000..7910a40ec35 --- /dev/null +++ b/homeassistant/components/mullvad/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "Set up the Mullvad VPN integration?" + } + } + } +} diff --git a/homeassistant/components/mullvad/translations/ca.json b/homeassistant/components/mullvad/translations/ca.json new file mode 100644 index 00000000000..f81781cbc0f --- /dev/null +++ b/homeassistant/components/mullvad/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Vols configurar la integraci\u00f3 Mullvad VPN?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/cs.json b/homeassistant/components/mullvad/translations/cs.json new file mode 100644 index 00000000000..0f02cd974c2 --- /dev/null +++ b/homeassistant/components/mullvad/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/de.json b/homeassistant/components/mullvad/translations/de.json new file mode 100644 index 00000000000..625c7372347 --- /dev/null +++ b/homeassistant/components/mullvad/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/en.json b/homeassistant/components/mullvad/translations/en.json new file mode 100644 index 00000000000..fcfa89ef082 --- /dev/null +++ b/homeassistant/components/mullvad/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + }, + "description": "Set up the Mullvad VPN integration?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/es.json b/homeassistant/components/mullvad/translations/es.json new file mode 100644 index 00000000000..d6a17561c3d --- /dev/null +++ b/homeassistant/components/mullvad/translations/es.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "\u00bfConfigurar la integraci\u00f3n VPN de Mullvad?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/et.json b/homeassistant/components/mullvad/translations/et.json new file mode 100644 index 00000000000..671d18a2cd3 --- /dev/null +++ b/homeassistant/components/mullvad/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendumine nurjus", + "invalid_auth": "Tuvastamise viga", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Kas seadistada Mullvad VPN sidumine?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/fr.json b/homeassistant/components/mullvad/translations/fr.json new file mode 100644 index 00000000000..1a8b10de809 --- /dev/null +++ b/homeassistant/components/mullvad/translations/fr.json @@ -0,0 +1,22 @@ +{ + "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", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Configurez l'int\u00e9gration VPN Mullvad?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/he.json b/homeassistant/components/mullvad/translations/he.json new file mode 100644 index 00000000000..7f60f15d598 --- /dev/null +++ b/homeassistant/components/mullvad/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "error": { + "cannot_connect": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05ea\u05e7\u05d9\u05df", + "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", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/it.json b/homeassistant/components/mullvad/translations/it.json new file mode 100644 index 00000000000..47cd8290f21 --- /dev/null +++ b/homeassistant/components/mullvad/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Nome utente" + }, + "description": "Configurare l'integrazione VPN Mullvad?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/nl.json b/homeassistant/components/mullvad/translations/nl.json new file mode 100644 index 00000000000..aa4d80ac71d --- /dev/null +++ b/homeassistant/components/mullvad/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "De Mullvad VPN-integratie instellen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/no.json b/homeassistant/components/mullvad/translations/no.json new file mode 100644 index 00000000000..d33f2640445 --- /dev/null +++ b/homeassistant/components/mullvad/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Sette opp Mullvad VPN-integrasjon?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/ru.json b/homeassistant/components/mullvad/translations/ru.json new file mode 100644 index 00000000000..ff34530e4a9 --- /dev/null +++ b/homeassistant/components/mullvad/translations/ru.json @@ -0,0 +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." + }, + "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": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/tr.json b/homeassistant/components/mullvad/translations/tr.json new file mode 100644 index 00000000000..0f3ddabfc4f --- /dev/null +++ b/homeassistant/components/mullvad/translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/zh-Hant.json b/homeassistant/components/mullvad/translations/zh-Hant.json new file mode 100644 index 00000000000..d78c36b72d7 --- /dev/null +++ b/homeassistant/components/mullvad/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a Mullvad VPN \u6574\u5408\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/my/__init__.py b/homeassistant/components/my/__init__.py new file mode 100644 index 00000000000..8cc725cb9a5 --- /dev/null +++ b/homeassistant/components/my/__init__.py @@ -0,0 +1,12 @@ +"""Support for my.home-assistant.io redirect service.""" + +DOMAIN = "my" +URL_PATH = "_my_redirect" + + +async def async_setup(hass, config): + """Register hidden _my_redirect panel.""" + hass.components.frontend.async_register_built_in_panel( + DOMAIN, frontend_url_path=URL_PATH + ) + return True diff --git a/homeassistant/components/my/manifest.json b/homeassistant/components/my/manifest.json new file mode 100644 index 00000000000..3b9e253f353 --- /dev/null +++ b/homeassistant/components/my/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "my", + "name": "My Home Assistant", + "documentation": "https://www.home-assistant.io/integrations/my", + "dependencies": ["frontend"], + "codeowners": ["@home-assistant/core"] +} diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 9dc8719ed4e..2098480af52 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.0.1"], + "requirements": ["pymyq==3.0.4"], "codeowners": ["@bdraco"], "config_flow": true, "homekit": { diff --git a/homeassistant/components/myq/translations/ko.json b/homeassistant/components/myq/translations/ko.json index 31e3f8646e6..23ba2eecea7 100644 --- a/homeassistant/components/myq/translations/ko.json +++ b/homeassistant/components/myq/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "MyQ \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/myq/translations/ru.json b/homeassistant/components/myq/translations/ru.json index daa3148beef..c3b113f148f 100644 --- a/homeassistant/components/myq/translations/ru.json +++ b/homeassistant/components/myq/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/myq/translations/sv.json b/homeassistant/components/myq/translations/sv.json index 1243ca600f0..da06f32aa92 100644 --- a/homeassistant/components/myq/translations/sv.json +++ b/homeassistant/components/myq/translations/sv.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "Tj\u00e4nsten har redan konfigurerats" + }, "error": { + "cannot_connect": "Anslutningen misslyckades", "invalid_auth": "Ogiltig autentisering", "unknown": "Ov\u00e4ntat fel" }, @@ -9,7 +13,8 @@ "data": { "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" - } + }, + "title": "Anslut till MyQ Gateway" } } } diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 43e398b142f..25b4d3106da 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,12 +1,18 @@ """Connect to a MySensors gateway via pymysensors API.""" +import asyncio import logging +from typing import Callable, Dict, List, Optional, Tuple, Type, Union +from mysensors import BaseAsyncGateway import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_OPTIMISTIC from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( ATTR_DEVICES, @@ -23,9 +29,14 @@ from .const import ( CONF_VERSION, DOMAIN, MYSENSORS_GATEWAYS, + MYSENSORS_ON_UNLOAD, + SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT, + DevId, + GatewayId, + SensorType, ) -from .device import get_mysensors_devices -from .gateway import finish_setup, get_mysensors_gateway, setup_gateways +from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices +from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -81,29 +92,38 @@ def deprecated(key): NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}}) -GATEWAY_SCHEMA = { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_PERSISTENCE_FILE): vol.All(cv.string, is_persistence_file), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, -} +GATEWAY_SCHEMA = vol.Schema( + vol.All( + deprecated(CONF_NODES), + { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_PERSISTENCE_FILE): vol.All( + cv.string, is_persistence_file + ), + vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, + vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, + }, + ) +) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( vol.All( deprecated(CONF_DEBUG), + deprecated(CONF_OPTIMISTIC), + deprecated(CONF_PERSISTENCE), { vol.Required(CONF_GATEWAYS): vol.All( cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA] ), - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, vol.Optional(CONF_RETAIN, default=True): cv.boolean, vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, }, ) ) @@ -112,69 +132,168 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the MySensors component.""" - gateways = await setup_gateways(hass, config) + if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)): + return True - if not gateways: - _LOGGER.error("No devices could be setup as gateways, check your configuration") - return False + config = config[DOMAIN] + user_inputs = [ + { + CONF_DEVICE: gw[CONF_DEVICE], + CONF_BAUD_RATE: gw[CONF_BAUD_RATE], + CONF_TCP_PORT: gw[CONF_TCP_PORT], + CONF_TOPIC_OUT_PREFIX: gw.get(CONF_TOPIC_OUT_PREFIX, ""), + CONF_TOPIC_IN_PREFIX: gw.get(CONF_TOPIC_IN_PREFIX, ""), + CONF_RETAIN: config[CONF_RETAIN], + CONF_VERSION: config[CONF_VERSION], + CONF_PERSISTENCE_FILE: gw.get(CONF_PERSISTENCE_FILE) + # nodes config ignored at this time. renaming nodes can now be done from the frontend. + } + for gw in config[CONF_GATEWAYS] + ] + user_inputs = [ + {k: v for k, v in userinput.items() if v is not None} + for userinput in user_inputs + ] - hass.data[MYSENSORS_GATEWAYS] = gateways - - hass.async_create_task(finish_setup(hass, config, gateways)) + # there is an actual configuration in configuration.yaml, so we have to process it + for user_input in user_inputs: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=user_input, + ) + ) return True -def _get_mysensors_name(gateway, node_id, child_id): - """Return a name for a node child.""" - node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}" - node_name = next( - ( - node[CONF_NODE_NAME] - for conf_id, node in gateway.nodes_config.items() - if node.get(CONF_NODE_NAME) is not None and conf_id == node_id - ), - node_name, +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up an instance of the MySensors integration. + + Every instance has a connection to exactly one Gateway. + """ + gateway = await setup_gateway(hass, entry) + + if not gateway: + _LOGGER.error("Gateway setup failed for %s", entry.data) + return False + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} + hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + + async def finish(): + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT + ] + ) + await finish_setup(hass, entry, gateway) + + hass.async_create_task(finish()) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Remove an instance of the MySensors integration.""" + + gateway = get_mysensors_gateway(hass, entry.entry_id) + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT + ] + ) ) - return f"{node_name} {child_id}" + if not unload_ok: + return False + + key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) + if key in hass.data[DOMAIN]: + for fnct in hass.data[DOMAIN][key]: + fnct() + + del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] + + await gw_stop(hass, entry, gateway) + return True + + +async def on_unload( + hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable +) -> None: + """Register a callback to be called when entry is unloaded. + + This function is used by platforms to cleanup after themselves + """ + if isinstance(entry, GatewayId): + uniqueid = entry + else: + uniqueid = entry.entry_id + key = MYSENSORS_ON_UNLOAD.format(uniqueid) + if key not in hass.data[DOMAIN]: + hass.data[DOMAIN][key] = [] + hass.data[DOMAIN][key].append(fnct) @callback def setup_mysensors_platform( hass, - domain, - discovery_info, - device_class, - device_args=None, - async_add_entities=None, -): - """Set up a MySensors platform.""" + domain: str, # hass platform name + discovery_info: Optional[Dict[str, List[DevId]]], + device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]], + device_args: Optional[ + Tuple + ] = None, # extra arguments that will be given to the entity constructor + async_add_entities: Callable = None, +) -> Optional[List[MySensorsDevice]]: + """Set up a MySensors platform. + + Sets up a bunch of instances of a single platform that is supported by this integration. + The function is given a list of device ids, each one describing an instance to set up. + The function is also given a class. + A new instance of the class is created for every device id, and the device id is given to the constructor of the class + """ # Only act if called via MySensors by discovery event. # Otherwise gateway is not set up. if not discovery_info: + _LOGGER.debug("Skipping setup due to no discovery info") return None if device_args is None: device_args = () - new_devices = [] - new_dev_ids = discovery_info[ATTR_DEVICES] + new_devices: List[MySensorsDevice] = [] + new_dev_ids: List[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices = get_mysensors_devices(hass, domain) + devices: Dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) if dev_id in devices: + _LOGGER.debug( + "Skipping setup of %s for platform %s as it already exists", + dev_id, + domain, + ) continue gateway_id, node_id, child_id, value_type = dev_id - gateway = get_mysensors_gateway(hass, gateway_id) + gateway: Optional[BaseAsyncGateway] = get_mysensors_gateway(hass, gateway_id) if not gateway: + _LOGGER.warning("Skipping setup of %s, no gateway found", dev_id) continue device_class_copy = device_class if isinstance(device_class, dict): child = gateway.sensors[node_id].children[child_id] s_type = gateway.const.Presentation(child.type).name device_class_copy = device_class[s_type] - name = _get_mysensors_name(gateway, node_id, child_id) - args_copy = (*device_args, gateway, node_id, child_id, name, value_type) + args_copy = (*device_args, gateway_id, gateway, node_id, child_id, value_type) devices[dev_id] = device_class_copy(*args_copy) new_devices.append(devices[dev_id]) if new_devices: diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 4ec3c6e0abd..c4e12d170c0 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -1,4 +1,6 @@ """Support for MySensors binary sensors.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, @@ -10,7 +12,13 @@ from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorEntity, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType SENSORS = { "S_DOOR": "door", @@ -24,14 +32,30 @@ SENSORS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for binary sensors.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + @callback + def async_discover(discovery_info): + """Discover and add a MySensors binary_sensor.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsBinarySensor, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsBinarySensor, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index c318ccf7ec6..b1916fc4ed1 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -1,4 +1,6 @@ """MySensors platform that offers a Climate (MySensors-HVAC) component.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -13,7 +15,12 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType DICT_HA_TO_MYS = { HVAC_MODE_AUTO: "AutoChangeOver", @@ -32,14 +39,29 @@ FAN_LIST = ["Auto", "Min", "Normal", "Max"] OPERATION_LIST = [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_HEAT] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors climate.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors climate.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsHVAC, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsHVAC, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -62,15 +84,10 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): features = features | SUPPORT_TARGET_TEMPERATURE return features - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT + return TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT @property def current_temperature(self): @@ -159,7 +176,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[value_type] = value self.async_write_ha_state() @@ -170,7 +187,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan_mode self.async_write_ha_state() @@ -184,7 +201,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): DICT_HA_TO_MYS[hvac_mode], ack=1, ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[self.value_type] = hvac_mode self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py new file mode 100644 index 00000000000..d2cd7f3bccd --- /dev/null +++ b/homeassistant/components/mysensors/config_flow.py @@ -0,0 +1,338 @@ +"""Config flow for MySensors.""" +import logging +import os +from typing import Any, Dict, Optional + +from awesomeversion import ( + AwesomeVersion, + AwesomeVersionStrategy, + AwesomeVersionStrategyException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic +from homeassistant.components.mysensors import ( + CONF_DEVICE, + DEFAULT_BAUD_RATE, + DEFAULT_TCP_PORT, + is_persistence_file, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import CONF_RETAIN, CONF_VERSION, DEFAULT_VERSION + +# pylint: disable=unused-import +from .const import ( + CONF_BAUD_RATE, + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_ALL, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_PERSISTENCE_FILE, + CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, + DOMAIN, + ConfGatewayType, +) +from .gateway import MQTT_COMPONENT, is_serial_port, is_socket_address, try_connect + +_LOGGER = logging.getLogger(__name__) + + +def _get_schema_common(user_input: Dict[str, str]) -> dict: + """Create a schema with options common to all gateway types.""" + schema = { + vol.Required( + CONF_VERSION, + default="", + description={ + "suggested_value": user_input.get(CONF_VERSION, DEFAULT_VERSION) + }, + ): str, + vol.Optional(CONF_PERSISTENCE_FILE): str, + } + return schema + + +def _validate_version(version: str) -> Dict[str, str]: + """Validate a version string from the user.""" + version_okay = False + try: + version_okay = bool( + AwesomeVersion.ensure_strategy( + version, + [AwesomeVersionStrategy.SIMPLEVER, AwesomeVersionStrategy.SEMVER], + ) + ) + except AwesomeVersionStrategyException: + pass + if version_okay: + return {} + return {CONF_VERSION: "invalid_version"} + + +def _is_same_device( + gw_type: ConfGatewayType, user_input: Dict[str, str], entry: ConfigEntry +): + """Check if another ConfigDevice is actually the same as user_input. + + This function only compares addresses and tcp ports, so it is possible to fool it with tricks like port forwarding. + """ + if entry.data[CONF_DEVICE] != user_input[CONF_DEVICE]: + return False + if gw_type == CONF_GATEWAY_TYPE_TCP: + return entry.data[CONF_TCP_PORT] == user_input[CONF_TCP_PORT] + if gw_type == CONF_GATEWAY_TYPE_MQTT: + entry_topics = { + entry.data[CONF_TOPIC_IN_PREFIX], + entry.data[CONF_TOPIC_OUT_PREFIX], + } + return ( + user_input.get(CONF_TOPIC_IN_PREFIX) in entry_topics + or user_input.get(CONF_TOPIC_OUT_PREFIX) in entry_topics + ) + return True + + +class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + def __init__(self) -> None: + """Set up config flow.""" + self._gw_type: Optional[str] = None + + async def async_step_import(self, user_input: Optional[Dict[str, str]] = None): + """Import a config entry. + + This method is called by async_setup and it has already + prepared the dict to be compatible with what a user would have + entered from the frontend. + Therefore we process it as though it came from the frontend. + """ + if user_input[CONF_DEVICE] == MQTT_COMPONENT: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_MQTT + else: + try: + await self.hass.async_add_executor_job( + is_serial_port, user_input[CONF_DEVICE] + ) + except vol.Invalid: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_TCP + else: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_SERIAL + + result: Dict[str, Any] = await self.async_step_user(user_input=user_input) + if result["type"] == "form": + return self.async_abort(reason=next(iter(result["errors"].values()))) + return result + + async def async_step_user(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry from frontend user input.""" + schema = {vol.Required(CONF_GATEWAY_TYPE): vol.In(CONF_GATEWAY_TYPE_ALL)} + schema = vol.Schema(schema) + + if user_input is not None: + gw_type = self._gw_type = user_input[CONF_GATEWAY_TYPE] + input_pass = user_input if CONF_DEVICE in user_input else None + if gw_type == CONF_GATEWAY_TYPE_MQTT: + return await self.async_step_gw_mqtt(input_pass) + if gw_type == CONF_GATEWAY_TYPE_TCP: + return await self.async_step_gw_tcp(input_pass) + if gw_type == CONF_GATEWAY_TYPE_SERIAL: + return await self.async_step_gw_serial(input_pass) + + return self.async_show_form(step_id="user", data_schema=schema) + + async def async_step_gw_serial(self, user_input: Optional[Dict[str, str]] = None): + """Create config entry for a serial gateway.""" + errors = {} + if user_input is not None: + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_SERIAL, errors, user_input) + ) + if not errors: + return self._async_create_entry(user_input) + + user_input = user_input or {} + schema = _get_schema_common(user_input) + schema[ + vol.Required( + CONF_BAUD_RATE, + default=user_input.get(CONF_BAUD_RATE, DEFAULT_BAUD_RATE), + ) + ] = cv.positive_int + schema[ + vol.Required( + CONF_DEVICE, default=user_input.get(CONF_DEVICE, "/dev/ttyACM0") + ) + ] = str + + schema = vol.Schema(schema) + return self.async_show_form( + step_id="gw_serial", data_schema=schema, errors=errors + ) + + async def async_step_gw_tcp(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry for a tcp gateway.""" + errors = {} + if user_input is not None: + if CONF_TCP_PORT in user_input: + port: int = user_input[CONF_TCP_PORT] + if not (0 < port <= 65535): + errors[CONF_TCP_PORT] = "port_out_of_range" + + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_TCP, errors, user_input) + ) + if not errors: + return self._async_create_entry(user_input) + + user_input = user_input or {} + schema = _get_schema_common(user_input) + schema[ + vol.Required(CONF_DEVICE, default=user_input.get(CONF_DEVICE, "127.0.0.1")) + ] = str + # Don't use cv.port as that would show a slider *facepalm* + schema[ + vol.Optional( + CONF_TCP_PORT, default=user_input.get(CONF_TCP_PORT, DEFAULT_TCP_PORT) + ) + ] = vol.Coerce(int) + + schema = vol.Schema(schema) + return self.async_show_form(step_id="gw_tcp", data_schema=schema, errors=errors) + + def _check_topic_exists(self, topic: str) -> bool: + for other_config in self.hass.config_entries.async_entries(DOMAIN): + if topic == other_config.data.get( + CONF_TOPIC_IN_PREFIX + ) or topic == other_config.data.get(CONF_TOPIC_OUT_PREFIX): + return True + return False + + async def async_step_gw_mqtt(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry for a mqtt gateway.""" + errors = {} + if user_input is not None: + user_input[CONF_DEVICE] = MQTT_COMPONENT + + try: + valid_subscribe_topic(user_input[CONF_TOPIC_IN_PREFIX]) + except vol.Invalid: + errors[CONF_TOPIC_IN_PREFIX] = "invalid_subscribe_topic" + else: + if self._check_topic_exists(user_input[CONF_TOPIC_IN_PREFIX]): + errors[CONF_TOPIC_IN_PREFIX] = "duplicate_topic" + + try: + valid_publish_topic(user_input[CONF_TOPIC_OUT_PREFIX]) + except vol.Invalid: + errors[CONF_TOPIC_OUT_PREFIX] = "invalid_publish_topic" + if not errors: + if ( + user_input[CONF_TOPIC_IN_PREFIX] + == user_input[CONF_TOPIC_OUT_PREFIX] + ): + errors[CONF_TOPIC_OUT_PREFIX] = "same_topic" + elif self._check_topic_exists(user_input[CONF_TOPIC_OUT_PREFIX]): + errors[CONF_TOPIC_OUT_PREFIX] = "duplicate_topic" + + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_MQTT, errors, user_input) + ) + if not errors: + return self._async_create_entry(user_input) + + user_input = user_input or {} + schema = _get_schema_common(user_input) + schema[ + vol.Required(CONF_RETAIN, default=user_input.get(CONF_RETAIN, True)) + ] = bool + schema[ + vol.Required( + CONF_TOPIC_IN_PREFIX, default=user_input.get(CONF_TOPIC_IN_PREFIX, "") + ) + ] = str + schema[ + vol.Required( + CONF_TOPIC_OUT_PREFIX, default=user_input.get(CONF_TOPIC_OUT_PREFIX, "") + ) + ] = str + + schema = vol.Schema(schema) + return self.async_show_form( + step_id="gw_mqtt", data_schema=schema, errors=errors + ) + + @callback + def _async_create_entry( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Create the config entry.""" + return self.async_create_entry( + title=f"{user_input[CONF_DEVICE]}", + data={**user_input, CONF_GATEWAY_TYPE: self._gw_type}, + ) + + def _normalize_persistence_file(self, path: str) -> str: + return os.path.realpath(os.path.normcase(self.hass.config.path(path))) + + async def validate_common( + self, + gw_type: ConfGatewayType, + errors: Dict[str, str], + user_input: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + """Validate parameters common to all gateway types.""" + if user_input is not None: + errors.update(_validate_version(user_input.get(CONF_VERSION))) + + if gw_type != CONF_GATEWAY_TYPE_MQTT: + if gw_type == CONF_GATEWAY_TYPE_TCP: + verification_func = is_socket_address + else: + verification_func = is_serial_port + + try: + await self.hass.async_add_executor_job( + verification_func, user_input.get(CONF_DEVICE) + ) + except vol.Invalid: + errors[CONF_DEVICE] = ( + "invalid_ip" + if gw_type == CONF_GATEWAY_TYPE_TCP + else "invalid_serial" + ) + if CONF_PERSISTENCE_FILE in user_input: + try: + is_persistence_file(user_input[CONF_PERSISTENCE_FILE]) + except vol.Invalid: + errors[CONF_PERSISTENCE_FILE] = "invalid_persistence_file" + else: + real_persistence_path = self._normalize_persistence_file( + user_input[CONF_PERSISTENCE_FILE] + ) + for other_entry in self.hass.config_entries.async_entries(DOMAIN): + if CONF_PERSISTENCE_FILE not in other_entry.data: + continue + if real_persistence_path == self._normalize_persistence_file( + other_entry.data[CONF_PERSISTENCE_FILE] + ): + errors[CONF_PERSISTENCE_FILE] = "duplicate_persistence_file" + break + + for other_entry in self.hass.config_entries.async_entries(DOMAIN): + if _is_same_device(gw_type, user_input, other_entry): + errors["base"] = "already_configured" + break + + # if no errors so far, try to connect + if not errors and not await try_connect(self.hass, user_input): + errors["base"] = "cannot_connect" + + return errors diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index ccb646eb47e..66bee128d4d 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -1,33 +1,69 @@ """MySensors constants.""" from collections import defaultdict +from typing import Dict, List, Literal, Set, Tuple -ATTR_DEVICES = "devices" +ATTR_DEVICES: str = "devices" +ATTR_GATEWAY_ID: str = "gateway_id" -CONF_BAUD_RATE = "baud_rate" -CONF_DEVICE = "device" -CONF_GATEWAYS = "gateways" -CONF_NODES = "nodes" -CONF_PERSISTENCE = "persistence" -CONF_PERSISTENCE_FILE = "persistence_file" -CONF_RETAIN = "retain" -CONF_TCP_PORT = "tcp_port" -CONF_TOPIC_IN_PREFIX = "topic_in_prefix" -CONF_TOPIC_OUT_PREFIX = "topic_out_prefix" -CONF_VERSION = "version" +CONF_BAUD_RATE: str = "baud_rate" +CONF_DEVICE: str = "device" +CONF_GATEWAYS: str = "gateways" +CONF_NODES: str = "nodes" +CONF_PERSISTENCE: str = "persistence" +CONF_PERSISTENCE_FILE: str = "persistence_file" +CONF_RETAIN: str = "retain" +CONF_TCP_PORT: str = "tcp_port" +CONF_TOPIC_IN_PREFIX: str = "topic_in_prefix" +CONF_TOPIC_OUT_PREFIX: str = "topic_out_prefix" +CONF_VERSION: str = "version" +CONF_GATEWAY_TYPE: str = "gateway_type" +ConfGatewayType = Literal["Serial", "TCP", "MQTT"] +CONF_GATEWAY_TYPE_SERIAL: ConfGatewayType = "Serial" +CONF_GATEWAY_TYPE_TCP: ConfGatewayType = "TCP" +CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" +CONF_GATEWAY_TYPE_ALL: List[str] = [ + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, +] -DOMAIN = "mysensors" -MYSENSORS_GATEWAY_READY = "mysensors_gateway_ready_{}" -MYSENSORS_GATEWAYS = "mysensors_gateways" -PLATFORM = "platform" -SCHEMA = "schema" -CHILD_CALLBACK = "mysensors_child_callback_{}_{}_{}_{}" -NODE_CALLBACK = "mysensors_node_callback_{}_{}" -TYPE = "type" -UPDATE_DELAY = 0.1 -SERVICE_SEND_IR_CODE = "send_ir_code" +DOMAIN: str = "mysensors" +MYSENSORS_GATEWAY_READY: str = "mysensors_gateway_ready_{}" +MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" +MYSENSORS_GATEWAYS: str = "mysensors_gateways" +PLATFORM: str = "platform" +SCHEMA: str = "schema" +CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" +NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" +MYSENSORS_DISCOVERY = "mysensors_discovery_{}_{}" +MYSENSORS_ON_UNLOAD = "mysensors_on_unload_{}" +TYPE: str = "type" +UPDATE_DELAY: float = 0.1 -BINARY_SENSOR_TYPES = { +SERVICE_SEND_IR_CODE: str = "send_ir_code" + +SensorType = str +# S_DOOR, S_MOTION, S_SMOKE, ... + +ValueType = str +# V_TRIPPED, V_ARMED, V_STATUS, V_PERCENTAGE, ... + +GatewayId = str +# a unique id generated by config_flow.py and stored in the ConfigEntry as the entry id. +# +# Gateway may be fetched by giving the gateway id to get_mysensors_gateway() + +DevId = Tuple[GatewayId, int, int, int] +# describes the backend of a hass entity. Contents are: GatewayId, node_id, child_id, v_type as int +# +# The string version of v_type can be looked up in the enum gateway.const.SetReq of the appropriate BaseAsyncGateway +# Home Assistant Entities are quite limited and only ever do one thing. +# MySensors Nodes have multiple child_ids each with a s_type several associated v_types +# The MySensors integration brings these together by creating an entity for every v_type of every child_id of every node. +# The DevId tuple perfectly captures this. + +BINARY_SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = { "S_DOOR": {"V_TRIPPED"}, "S_MOTION": {"V_TRIPPED"}, "S_SMOKE": {"V_TRIPPED"}, @@ -38,21 +74,23 @@ BINARY_SENSOR_TYPES = { "S_MOISTURE": {"V_TRIPPED"}, } -CLIMATE_TYPES = {"S_HVAC": {"V_HVAC_FLOW_STATE"}} +CLIMATE_TYPES: Dict[SensorType, Set[ValueType]] = {"S_HVAC": {"V_HVAC_FLOW_STATE"}} -COVER_TYPES = {"S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"}} +COVER_TYPES: Dict[SensorType, Set[ValueType]] = { + "S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"} +} -DEVICE_TRACKER_TYPES = {"S_GPS": {"V_POSITION"}} +DEVICE_TRACKER_TYPES: Dict[SensorType, Set[ValueType]] = {"S_GPS": {"V_POSITION"}} -LIGHT_TYPES = { +LIGHT_TYPES: Dict[SensorType, Set[ValueType]] = { "S_DIMMER": {"V_DIMMER", "V_PERCENTAGE"}, "S_RGB_LIGHT": {"V_RGB"}, "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES = {"S_INFO": {"V_TEXT"}} +NOTIFY_TYPES: Dict[SensorType, Set[ValueType]] = {"S_INFO": {"V_TEXT"}} -SENSOR_TYPES = { +SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = { "S_SOUND": {"V_LEVEL"}, "S_VIBRATION": {"V_LEVEL"}, "S_MOISTURE": {"V_LEVEL"}, @@ -80,7 +118,7 @@ SENSOR_TYPES = { "S_DUST": {"V_DUST_LEVEL", "V_LEVEL"}, } -SWITCH_TYPES = { +SWITCH_TYPES: Dict[SensorType, Set[ValueType]] = { "S_LIGHT": {"V_LIGHT"}, "S_BINARY": {"V_STATUS"}, "S_DOOR": {"V_ARMED"}, @@ -97,7 +135,7 @@ SWITCH_TYPES = { } -PLATFORM_TYPES = { +PLATFORM_TYPES: Dict[str, Dict[SensorType, Set[ValueType]]] = { "binary_sensor": BINARY_SENSOR_TYPES, "climate": CLIMATE_TYPES, "cover": COVER_TYPES, @@ -108,13 +146,19 @@ PLATFORM_TYPES = { "switch": SWITCH_TYPES, } -FLAT_PLATFORM_TYPES = { +FLAT_PLATFORM_TYPES: Dict[Tuple[str, SensorType], Set[ValueType]] = { (platform, s_type_name): v_type_name for platform, platform_types in PLATFORM_TYPES.items() for s_type_name, v_type_name in platform_types.items() } -TYPE_TO_PLATFORMS = defaultdict(list) +TYPE_TO_PLATFORMS: Dict[SensorType, List[str]] = defaultdict(list) + for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) + +SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { + "notify", + "device_tracker", +} diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index f2ede69793f..782ab88c488 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -1,28 +1,48 @@ """Support for MySensors covers.""" +import logging +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverEntity +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for covers.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors cover.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsCover, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsCover, - async_add_entities=async_add_entities, + config_entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def is_closed(self): """Return True if cover is closed.""" @@ -46,7 +66,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_UP, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. if set_req.V_DIMMER in self._values: self._values[set_req.V_DIMMER] = 100 @@ -60,7 +80,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. if set_req.V_DIMMER in self._values: self._values[set_req.V_DIMMER] = 0 @@ -75,7 +95,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. self._values[set_req.V_DIMMER] = position self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 9c1c4b54367..68414867345 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,13 +1,26 @@ """Handle MySensors devices.""" from functools import partial import logging +from typing import Any, Dict, Optional + +from mysensors import BaseAsyncGateway, Sensor +from mysensors.sensor import ChildSensor from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import CHILD_CALLBACK, NODE_CALLBACK, UPDATE_DELAY +from .const import ( + CHILD_CALLBACK, + CONF_DEVICE, + DOMAIN, + NODE_CALLBACK, + PLATFORM_TYPES, + UPDATE_DELAY, + DevId, + GatewayId, +) _LOGGER = logging.getLogger(__name__) @@ -19,33 +32,94 @@ ATTR_HEARTBEAT = "heartbeat" MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" -def get_mysensors_devices(hass, domain): - """Return MySensors devices for a platform.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: - hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] - - class MySensorsDevice: """Representation of a MySensors device.""" - def __init__(self, gateway, node_id, child_id, name, value_type): + def __init__( + self, + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child_id: int, + value_type: int, + ): """Set up the MySensors device.""" - self.gateway = gateway - self.node_id = node_id - self.child_id = child_id - self._name = name - self.value_type = value_type - child = gateway.sensors[node_id].children[child_id] - self.child_type = child.type + self.gateway_id: GatewayId = gateway_id + self.gateway: BaseAsyncGateway = gateway + self.node_id: int = node_id + self.child_id: int = child_id + self.value_type: int = value_type # value_type as int. string variant can be looked up in gateway consts + self.child_type = self._child.type self._values = {} self._update_scheduled = False self.hass = None + @property + def dev_id(self) -> DevId: + """Return the DevId of this device. + + It is used to route incoming MySensors messages to the correct device/entity. + """ + return self.gateway_id, self.node_id, self.child_id, self.value_type + + @property + def _logger(self): + return logging.getLogger(f"{__name__}.{self.name}") + + async def async_will_remove_from_hass(self): + """Remove this entity from home assistant.""" + for platform in PLATFORM_TYPES: + platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) + if platform_str in self.hass.data[DOMAIN]: + platform_dict = self.hass.data[DOMAIN][platform_str] + if self.dev_id in platform_dict: + del platform_dict[self.dev_id] + self._logger.debug( + "deleted %s from platform %s", self.dev_id, platform + ) + + @property + def _node(self) -> Sensor: + return self.gateway.sensors[self.node_id] + + @property + def _child(self) -> ChildSensor: + return self._node.children[self.child_id] + + @property + def sketch_name(self) -> str: + """Return the name of the sketch running on the whole node (will be the same for several entities!).""" + return self._node.sketch_name + + @property + def sketch_version(self) -> str: + """Return the version of the sketch running on the whole node (will be the same for several entities!).""" + return self._node.sketch_version + + @property + def node_name(self) -> str: + """Name of the whole node (will be the same for several entities!).""" + return f"{self.sketch_name} {self.node_id}" + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" + + @property + def device_info(self) -> Optional[Dict[str, Any]]: + """Return a dict that allows home assistant to puzzle all entities belonging to a node together.""" + return { + "identifiers": {(DOMAIN, f"{self.gateway_id}-{self.node_id}")}, + "name": self.node_name, + "manufacturer": DOMAIN, + "sw_version": self.sketch_version, + } + @property def name(self): """Return the name of this entity.""" - return self._name + return f"{self.node_name} {self.child_id}" @property def device_state_attributes(self): @@ -57,9 +131,12 @@ class MySensorsDevice: ATTR_HEARTBEAT: node.heartbeat, ATTR_CHILD_ID: self.child_id, ATTR_DESCRIPTION: child.description, - ATTR_DEVICE: self.gateway.device, ATTR_NODE_ID: self.node_id, } + # This works when we are actually an Entity (i.e. all platforms except device_tracker) + if hasattr(self, "platform"): + # pylint: disable=no-member + attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] set_req = self.gateway.const.SetReq @@ -76,7 +153,7 @@ class MySensorsDevice: for value_type, value in child.values.items(): _LOGGER.debug( "Entity update: %s: value_type %s, value = %s", - self._name, + self.name, value_type, value, ) @@ -116,6 +193,13 @@ class MySensorsDevice: self.hass.loop.call_later(UPDATE_DELAY, delayed_update) +def get_mysensors_devices(hass, domain: str) -> Dict[DevId, MySensorsDevice]: + """Return MySensors devices for a hass platform name.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] + + class MySensorsEntity(MySensorsDevice, Entity): """Representation of a MySensors entity.""" @@ -135,17 +219,17 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self): """Register update callback.""" - gateway_id = id(self.gateway) - dev_id = gateway_id, self.node_id, self.child_id, self.value_type self.async_on_remove( async_dispatcher_connect( - self.hass, CHILD_CALLBACK.format(*dev_id), self.async_update_callback + self.hass, + CHILD_CALLBACK.format(*self.dev_id), + self.async_update_callback, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - NODE_CALLBACK.format(gateway_id, self.node_id), + NODE_CALLBACK.format(self.gateway_id, self.node_id), self.async_update_callback, ) ) diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 1bf1e072ceb..b395a48f28b 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -1,11 +1,16 @@ """Support for tracking MySensors devices.""" from homeassistant.components import mysensors from homeassistant.components.device_tracker import DOMAIN +from homeassistant.components.mysensors import DevId, on_unload +from homeassistant.components.mysensors.const import ATTR_GATEWAY_ID, GatewayId from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import slugify -async def async_setup_scanner(hass, config, async_see, discovery_info=None): +async def async_setup_scanner( + hass: HomeAssistantType, config, async_see, discovery_info=None +): """Set up the MySensors device scanner.""" new_devices = mysensors.setup_mysensors_platform( hass, @@ -18,17 +23,25 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): return False for device in new_devices: - gateway_id = id(device.gateway) - dev_id = (gateway_id, device.node_id, device.child_id, device.value_type) - async_dispatcher_connect( + gateway_id: GatewayId = discovery_info[ATTR_GATEWAY_ID] + dev_id: DevId = (gateway_id, device.node_id, device.child_id, device.value_type) + await on_unload( hass, - mysensors.const.CHILD_CALLBACK.format(*dev_id), - device.async_update_callback, + gateway_id, + async_dispatcher_connect( + hass, + mysensors.const.CHILD_CALLBACK.format(*dev_id), + device.async_update_callback, + ), ) - async_dispatcher_connect( + await on_unload( hass, - mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), - device.async_update_callback, + gateway_id, + async_dispatcher_connect( + hass, + mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), + device.async_update_callback, + ), ) return True @@ -37,7 +50,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" - def __init__(self, hass, async_see, *args): + def __init__(self, hass: HomeAssistantType, async_see, *args): """Set up instance.""" super().__init__(*args) self.async_see = async_see diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index f9450b798ac..4267ba5cbb3 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -4,22 +4,21 @@ from collections import defaultdict import logging import socket import sys +from typing import Any, Callable, Coroutine, Dict, Optional import async_timeout -from mysensors import mysensors +from mysensors import BaseAsyncGateway, Message, Sensor, mysensors import voluptuous as vol -from homeassistant.const import CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, callback import homeassistant.helpers.config_validation as cv -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import HomeAssistantType from .const import ( CONF_BAUD_RATE, CONF_DEVICE, - CONF_GATEWAYS, - CONF_NODES, - CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, @@ -28,7 +27,9 @@ from .const import ( CONF_VERSION, DOMAIN, MYSENSORS_GATEWAY_READY, + MYSENSORS_GATEWAY_START_TASK, MYSENSORS_GATEWAYS, + GatewayId, ) from .handler import HANDLERS from .helpers import discover_mysensors_platform, validate_child, validate_node @@ -58,48 +59,114 @@ def is_socket_address(value): raise vol.Invalid("Device is not a valid domain name or ip address") from err -def get_mysensors_gateway(hass, gateway_id): - """Return MySensors gateway.""" - if MYSENSORS_GATEWAYS not in hass.data: - hass.data[MYSENSORS_GATEWAYS] = {} - gateways = hass.data.get(MYSENSORS_GATEWAYS) +async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bool: + """Try to connect to a gateway and report if it worked.""" + if user_input[CONF_DEVICE] == MQTT_COMPONENT: + return True # dont validate mqtt. mqtt gateways dont send ready messages :( + try: + gateway_ready = asyncio.Future() + + def gateway_ready_callback(msg): + msg_type = msg.gateway.const.MessageType(msg.type) + _LOGGER.debug("Received MySensors msg type %s: %s", msg_type.name, msg) + if msg_type.name != "internal": + return + internal = msg.gateway.const.Internal(msg.sub_type) + if internal.name != "I_GATEWAY_READY": + return + _LOGGER.debug("Received gateway ready") + gateway_ready.set_result(True) + + gateway: Optional[BaseAsyncGateway] = await _get_gateway( + hass, + device=user_input[CONF_DEVICE], + version=user_input[CONF_VERSION], + event_callback=gateway_ready_callback, + persistence_file=None, + baud_rate=user_input.get(CONF_BAUD_RATE), + tcp_port=user_input.get(CONF_TCP_PORT), + topic_in_prefix=None, + topic_out_prefix=None, + retain=False, + persistence=False, + ) + if gateway is None: + return False + + connect_task = None + try: + connect_task = asyncio.create_task(gateway.start()) + with async_timeout.timeout(20): + await gateway_ready + return True + except asyncio.TimeoutError: + _LOGGER.info("Try gateway connect failed with timeout") + return False + finally: + if connect_task is not None and not connect_task.done(): + connect_task.cancel() + asyncio.create_task(gateway.stop()) + except OSError as err: + _LOGGER.info("Try gateway connect failed with exception", exc_info=err) + return False + + +def get_mysensors_gateway( + hass: HomeAssistantType, gateway_id: GatewayId +) -> Optional[BaseAsyncGateway]: + """Return the Gateway for a given GatewayId.""" + if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} + gateways = hass.data[DOMAIN].get(MYSENSORS_GATEWAYS) return gateways.get(gateway_id) -async def setup_gateways(hass, config): - """Set up all gateways.""" - conf = config[DOMAIN] - gateways = {} +async def setup_gateway( + hass: HomeAssistantType, entry: ConfigEntry +) -> Optional[BaseAsyncGateway]: + """Set up the Gateway for the given ConfigEntry.""" - for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): - persistence_file = gateway_conf.get( - CONF_PERSISTENCE_FILE, - hass.config.path(f"mysensors{index + 1}.pickle"), - ) - ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file) - if ready_gateway is not None: - gateways[id(ready_gateway)] = ready_gateway - - return gateways + ready_gateway = await _get_gateway( + hass, + device=entry.data[CONF_DEVICE], + version=entry.data[CONF_VERSION], + event_callback=_gw_callback_factory(hass, entry.entry_id), + persistence_file=entry.data.get( + CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json" + ), + baud_rate=entry.data.get(CONF_BAUD_RATE), + tcp_port=entry.data.get(CONF_TCP_PORT), + topic_in_prefix=entry.data.get(CONF_TOPIC_IN_PREFIX), + topic_out_prefix=entry.data.get(CONF_TOPIC_OUT_PREFIX), + retain=entry.data.get(CONF_RETAIN, False), + ) + return ready_gateway -async def _get_gateway(hass, config, gateway_conf, persistence_file): +async def _get_gateway( + hass: HomeAssistantType, + device: str, + version: str, + event_callback: Callable[[Message], None], + persistence_file: Optional[str] = None, + baud_rate: Optional[int] = None, + tcp_port: Optional[int] = None, + topic_in_prefix: Optional[str] = None, + topic_out_prefix: Optional[str] = None, + retain: bool = False, + persistence: bool = True, # old persistence option has been deprecated. kwarg is here so we can run try_connect() without persistence +) -> Optional[BaseAsyncGateway]: """Return gateway after setup of the gateway.""" - conf = config[DOMAIN] - persistence = conf[CONF_PERSISTENCE] - version = conf[CONF_VERSION] - device = gateway_conf[CONF_DEVICE] - baud_rate = gateway_conf[CONF_BAUD_RATE] - tcp_port = gateway_conf[CONF_TCP_PORT] - in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, "") - out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, "") + if persistence_file is not None: + # interpret relative paths to be in hass config folder. absolute paths will be left as they are + persistence_file = hass.config.path(persistence_file) if device == MQTT_COMPONENT: - if not await async_setup_component(hass, MQTT_COMPONENT, config): - return None + # what is the purpose of this? + # if not await async_setup_component(hass, MQTT_COMPONENT, entry): + # return None mqtt = hass.components.mqtt - retain = conf[CONF_RETAIN] def pub_callback(topic, payload, qos, retain): """Call MQTT publish function.""" @@ -118,8 +185,8 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): gateway = mysensors.AsyncMQTTGateway( pub_callback, sub_callback, - in_prefix=in_prefix, - out_prefix=out_prefix, + in_prefix=topic_in_prefix, + out_prefix=topic_out_prefix, retain=retain, loop=hass.loop, event_callback=None, @@ -154,25 +221,23 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): ) except vol.Invalid: # invalid ip address + _LOGGER.error("Connect failed: Invalid device %s", device) return None - gateway.metric = hass.config.units.is_metric - gateway.optimistic = conf[CONF_OPTIMISTIC] - gateway.device = device - gateway.event_callback = _gw_callback_factory(hass, config) - gateway.nodes_config = gateway_conf[CONF_NODES] + gateway.event_callback = event_callback if persistence: await gateway.start_persistence() return gateway -async def finish_setup(hass, hass_config, gateways): +async def finish_setup( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Load any persistent devices and platforms and start gateway.""" discover_tasks = [] start_tasks = [] - for gateway in gateways.values(): - discover_tasks.append(_discover_persistent_devices(hass, hass_config, gateway)) - start_tasks.append(_gw_start(hass, gateway)) + 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) @@ -180,43 +245,58 @@ async def finish_setup(hass, hass_config, gateways): await asyncio.wait(start_tasks) -async def _discover_persistent_devices(hass, hass_config, gateway): +async def _discover_persistent_devices( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Discover platforms for devices loaded via persistence file.""" tasks = [] new_devices = defaultdict(list) for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue - node = gateway.sensors[node_id] - for child in node.children.values(): - validated = validate_child(gateway, node_id, child) + node: Sensor = gateway.sensors[node_id] + for child in node.children.values(): # child is of type ChildSensor + validated = validate_child(entry.entry_id, gateway, node_id, child) for platform, dev_ids in validated.items(): new_devices[platform].extend(dev_ids) + _LOGGER.debug("discovering persistent devices: %s", new_devices) for platform, dev_ids in new_devices.items(): - tasks.append(discover_mysensors_platform(hass, hass_config, platform, dev_ids)) + discover_mysensors_platform(hass, entry.entry_id, platform, dev_ids) if tasks: await asyncio.wait(tasks) -async def _gw_start(hass, gateway): +async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway): + """Stop the gateway.""" + connect_task = hass.data[DOMAIN].get( + MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id) + ) + if connect_task is not None and not connect_task.done(): + connect_task.cancel() + await gateway.stop() + + +async def _gw_start( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Start the gateway.""" # Don't use hass.async_create_task to avoid holding up setup indefinitely. - connect_task = hass.loop.create_task(gateway.start()) + hass.data[DOMAIN][ + MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id) + ] = asyncio.create_task( + gateway.start() + ) # store the connect task so it can be cancelled in gw_stop - @callback - def gw_stop(event): - """Trigger to stop the gateway.""" - hass.async_create_task(gateway.stop()) - if not connect_task.done(): - connect_task.cancel() + async def stop_this_gw(_: Event): + await gw_stop(hass, entry, gateway) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) - if gateway.device == "mqtt": + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw) + if entry.data[CONF_DEVICE] == MQTT_COMPONENT: # Gatways connected via mqtt doesn't send gateway ready message. return gateway_ready = asyncio.Future() - gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) - hass.data[gateway_ready_key] = gateway_ready + gateway_ready_key = MYSENSORS_GATEWAY_READY.format(entry.entry_id) + hass.data[DOMAIN][gateway_ready_key] = gateway_ready try: with async_timeout.timeout(GATEWAY_READY_TIMEOUT): @@ -224,27 +304,35 @@ async def _gw_start(hass, gateway): except asyncio.TimeoutError: _LOGGER.warning( "Gateway %s not ready after %s secs so continuing with setup", - gateway.device, + entry.data[CONF_DEVICE], GATEWAY_READY_TIMEOUT, ) finally: - hass.data.pop(gateway_ready_key, None) + hass.data[DOMAIN].pop(gateway_ready_key, None) -def _gw_callback_factory(hass, hass_config): +def _gw_callback_factory( + hass: HomeAssistantType, gateway_id: GatewayId +) -> Callable[[Message], None]: """Return a new callback for the gateway.""" @callback - def mysensors_callback(msg): - """Handle messages from a MySensors gateway.""" + def mysensors_callback(msg: Message): + """Handle messages from a MySensors gateway. + + All MySenors messages are received here. + The messages are passed to handler functions depending on their type. + """ _LOGGER.debug("Node update: node %s child %s", msg.node_id, msg.child_id) msg_type = msg.gateway.const.MessageType(msg.type) - msg_handler = HANDLERS.get(msg_type.name) + msg_handler: Callable[ + [Any, GatewayId, Message], Coroutine[None] + ] = HANDLERS.get(msg_type.name) if msg_handler is None: return - hass.async_create_task(msg_handler(hass, hass_config, msg)) + hass.async_create_task(msg_handler(hass, gateway_id, msg)) return mysensors_callback diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index b5b8b511aee..10165a171e0 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -1,9 +1,21 @@ """Handle MySensors messages.""" +from typing import Dict, List + +from mysensors import Message + from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import decorator -from .const import CHILD_CALLBACK, MYSENSORS_GATEWAY_READY, NODE_CALLBACK +from .const import ( + CHILD_CALLBACK, + DOMAIN, + MYSENSORS_GATEWAY_READY, + NODE_CALLBACK, + DevId, + GatewayId, +) from .device import get_mysensors_devices from .helpers import discover_mysensors_platform, validate_set_msg @@ -11,75 +23,91 @@ HANDLERS = decorator.Registry() @HANDLERS.register("set") -async def handle_set(hass, hass_config, msg): +async def handle_set( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle a mysensors set message.""" - validated = validate_set_msg(msg) - _handle_child_update(hass, hass_config, validated) + validated = validate_set_msg(gateway_id, msg) + _handle_child_update(hass, gateway_id, validated) @HANDLERS.register("internal") -async def handle_internal(hass, hass_config, msg): +async def handle_internal( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle a mysensors internal message.""" internal = msg.gateway.const.Internal(msg.sub_type) handler = HANDLERS.get(internal.name) if handler is None: return - await handler(hass, hass_config, msg) + await handler(hass, gateway_id, msg) @HANDLERS.register("I_BATTERY_LEVEL") -async def handle_battery_level(hass, hass_config, msg): +async def handle_battery_level( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal battery level message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_HEARTBEAT_RESPONSE") -async def handle_heartbeat(hass, hass_config, msg): +async def handle_heartbeat( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an heartbeat.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_SKETCH_NAME") -async def handle_sketch_name(hass, hass_config, msg): +async def handle_sketch_name( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal sketch name message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_SKETCH_VERSION") -async def handle_sketch_version(hass, hass_config, msg): +async def handle_sketch_version( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal sketch version message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_GATEWAY_READY") -async def handle_gateway_ready(hass, hass_config, msg): +async def handle_gateway_ready( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal gateway ready message. Set asyncio future result if gateway is ready. """ - gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format(id(msg.gateway))) + gateway_ready = hass.data[DOMAIN].get(MYSENSORS_GATEWAY_READY.format(gateway_id)) if gateway_ready is None or gateway_ready.cancelled(): return gateway_ready.set_result(True) @callback -def _handle_child_update(hass, hass_config, validated): +def _handle_child_update( + hass: HomeAssistantType, gateway_id: GatewayId, validated: Dict[str, List[DevId]] +): """Handle a child update.""" - signals = [] + signals: List[str] = [] # Update all platforms for the device via dispatcher. # Add/update entity for validated children. for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] + new_dev_ids: List[DevId] = [] for dev_id in dev_ids: if dev_id in devices: signals.append(CHILD_CALLBACK.format(*dev_id)) else: new_dev_ids.append(dev_id) if new_dev_ids: - discover_mysensors_platform(hass, hass_config, platform, new_dev_ids) + discover_mysensors_platform(hass, gateway_id, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. @@ -87,7 +115,7 @@ def _handle_child_update(hass, hass_config, validated): @callback -def _handle_node_update(hass, msg): +def _handle_node_update(hass: HomeAssistantType, gateway_id: GatewayId, msg: Message): """Handle a node update.""" - signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id) + signal = NODE_CALLBACK.format(gateway_id, msg.node_id) async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 20b266e550e..d06bf0dee2f 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -1,78 +1,109 @@ """Helper functions for mysensors package.""" from collections import defaultdict +from enum import IntEnum import logging +from typing import DefaultDict, Dict, List, Optional, Set +from mysensors import BaseAsyncGateway, Message +from mysensors.sensor import ChildSensor import voluptuous as vol from homeassistant.const import CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry -from .const import ATTR_DEVICES, DOMAIN, FLAT_PLATFORM_TYPES, TYPE_TO_PLATFORMS +from .const import ( + ATTR_DEVICES, + ATTR_GATEWAY_ID, + DOMAIN, + FLAT_PLATFORM_TYPES, + MYSENSORS_DISCOVERY, + TYPE_TO_PLATFORMS, + DevId, + GatewayId, + SensorType, + ValueType, +) _LOGGER = logging.getLogger(__name__) SCHEMAS = Registry() @callback -def discover_mysensors_platform(hass, hass_config, platform, new_devices): +def discover_mysensors_platform( + hass, gateway_id: GatewayId, platform: str, new_devices: List[DevId] +) -> None: """Discover a MySensors platform.""" - task = hass.async_create_task( - discovery.async_load_platform( - hass, - platform, - DOMAIN, - {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, - hass_config, - ) + _LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices) + async_dispatcher_send( + hass, + MYSENSORS_DISCOVERY.format(gateway_id, platform), + { + ATTR_DEVICES: new_devices, + CONF_NAME: DOMAIN, + ATTR_GATEWAY_ID: gateway_id, + }, ) - return task -def default_schema(gateway, child, value_type_name): +def default_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a default validation schema for value types.""" schema = {value_type_name: cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_DIMMER")) -def light_dimmer_schema(gateway, child, value_type_name): +def light_dimmer_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_DIMMER.""" schema = {"V_DIMMER": cv.string, "V_LIGHT": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_PERCENTAGE")) -def light_percentage_schema(gateway, child, value_type_name): +def light_percentage_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_PERCENTAGE.""" schema = {"V_PERCENTAGE": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_RGB")) -def light_rgb_schema(gateway, child, value_type_name): +def light_rgb_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_RGB.""" schema = {"V_RGB": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_RGBW")) -def light_rgbw_schema(gateway, child, value_type_name): +def light_rgbw_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_RGBW.""" schema = {"V_RGBW": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("switch", "V_IR_SEND")) -def switch_ir_send_schema(gateway, child, value_type_name): +def switch_ir_send_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_IR_SEND.""" schema = {"V_IR_SEND": cv.string, "V_LIGHT": cv.string} return get_child_schema(gateway, child, value_type_name, schema) -def get_child_schema(gateway, child, value_type_name, schema): +def get_child_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType, schema +) -> vol.Schema: """Return a child schema.""" set_req = gateway.const.SetReq child_schema = child.get_schema(gateway.protocol_version) @@ -88,7 +119,9 @@ def get_child_schema(gateway, child, value_type_name, schema): return schema -def invalid_msg(gateway, child, value_type_name): +def invalid_msg( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +): """Return a message for an invalid child during schema validation.""" pres = gateway.const.Presentation set_req = gateway.const.SetReq @@ -97,15 +130,15 @@ def invalid_msg(gateway, child, value_type_name): ) -def validate_set_msg(msg): +def validate_set_msg(gateway_id: GatewayId, msg: Message) -> Dict[str, List[DevId]]: """Validate a set message.""" if not validate_node(msg.gateway, msg.node_id): return {} child = msg.gateway.sensors[msg.node_id].children[msg.child_id] - return validate_child(msg.gateway, msg.node_id, child, msg.sub_type) + return validate_child(gateway_id, msg.gateway, msg.node_id, child, msg.sub_type) -def validate_node(gateway, node_id): +def validate_node(gateway: BaseAsyncGateway, node_id: int) -> bool: """Validate a node.""" if gateway.sensors[node_id].sketch_name is None: _LOGGER.debug("Node %s is missing sketch name", node_id) @@ -113,31 +146,39 @@ def validate_node(gateway, node_id): return True -def validate_child(gateway, node_id, child, value_type=None): - """Validate a child.""" - validated = defaultdict(list) - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - child_type_name = next( +def validate_child( + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child: ChildSensor, + value_type: Optional[int] = None, +) -> DefaultDict[str, List[DevId]]: + """Validate a child. Returns a dict mapping hass platform names to list of DevId.""" + validated: DefaultDict[str, List[DevId]] = defaultdict(list) + pres: IntEnum = gateway.const.Presentation + set_req: IntEnum = gateway.const.SetReq + child_type_name: Optional[SensorType] = next( (member.name for member in pres if member.value == child.type), None ) - value_types = {value_type} if value_type else {*child.values} - value_type_names = { + value_types: Set[int] = {value_type} if value_type else {*child.values} + value_type_names: Set[ValueType] = { member.name for member in set_req if member.value in value_types } - platforms = TYPE_TO_PLATFORMS.get(child_type_name, []) + platforms: List[str] = TYPE_TO_PLATFORMS.get(child_type_name, []) if not platforms: _LOGGER.warning("Child type %s is not supported", child.type) return validated for platform in platforms: - platform_v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] - v_names = platform_v_names & value_type_names + platform_v_names: Set[ValueType] = FLAT_PLATFORM_TYPES[ + platform, child_type_name + ] + v_names: Set[ValueType] = platform_v_names & value_type_names if not v_names: - child_value_names = { + child_value_names: Set[ValueType] = { member.name for member in set_req if member.value in child.values } - v_names = platform_v_names & child_value_names + v_names: Set[ValueType] = platform_v_names & child_value_names for v_name in v_names: child_schema_gen = SCHEMAS.get((platform, v_name), default_schema) @@ -153,7 +194,12 @@ def validate_child(gateway, node_id, child, value_type=None): exc, ) continue - dev_id = id(gateway), node_id, child.id, set_req[v_name].value + dev_id: DevId = ( + gateway_id, + node_id, + child.id, + set_req[v_name].value, + ) validated[platform].append(dev_id) return validated diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index ffbcba6f032..f90f9c5c81c 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -1,4 +1,6 @@ """Support for MySensors lights.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -10,27 +12,47 @@ from homeassistant.components.light import ( SUPPORT_WHITE_VALUE, LightEntity, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util from homeassistant.util.color import rgb_hex_to_rgb_list SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for lights.""" +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, } - mysensors.setup_mysensors_platform( + + async def async_discover(discovery_info): + """Discover and add a MySensors light.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + device_class_map, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - device_class_map, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -60,11 +82,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Return the white value of this light between 0..255.""" return self._white - @property - def assumed_state(self): - """Return true if unable to access real state of entity.""" - return self.gateway.optimistic - @property def is_on(self): """Return true if device is on.""" @@ -80,7 +97,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._state = True self._values[set_req.V_LIGHT] = STATE_ON @@ -102,7 +119,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._brightness = brightness self._values[set_req.V_DIMMER] = percent @@ -135,7 +152,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._hs = color_util.color_RGB_to_hs(*rgb) self._white = white @@ -145,7 +162,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._state = False self._values[value_type] = STATE_OFF @@ -188,7 +205,7 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() async def async_update(self): @@ -214,7 +231,7 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() async def async_update(self): @@ -241,5 +258,5 @@ class MySensorsLightRGBW(MySensorsLightRGB): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w("%02x%02x%02x%02x", **kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index afeeb5d57cc..8371f2930c2 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -2,7 +2,15 @@ "domain": "mysensors", "name": "MySensors", "documentation": "https://www.home-assistant.io/integrations/mysensors", - "requirements": ["pymysensors==0.18.0"], - "after_dependencies": ["mqtt"], - "codeowners": ["@MartinHjelmare"] + "requirements": [ + "pymysensors==0.20.1" + ], + "after_dependencies": [ + "mqtt" + ], + "codeowners": [ + "@MartinHjelmare", + "@functionpointer" + ], + "config_flow": true } diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index bab6bf3fc40..a09f8af1394 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,6 +1,11 @@ """Support for MySensors sensors.""" +from typing import Callable + from homeassistant.components import mysensors +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY from homeassistant.components.sensor import DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONDUCTIVITY, DEGREE, @@ -18,6 +23,8 @@ from homeassistant.const import ( VOLT, VOLUME_CUBIC_METERS, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType SENSORS = { "V_TEMP": [None, "mdi:thermometer"], @@ -54,14 +61,29 @@ SENSORS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the MySensors platform for sensors.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors sensor.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsSensor, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsSensor, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -105,7 +127,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity): pres = self.gateway.const.Presentation set_req = self.gateway.const.SetReq SENSORS[set_req.V_TEMP.name][0] = ( - TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT + TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT ) sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None]) if isinstance(sensor_type, dict): diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json new file mode 100644 index 00000000000..43a68f61e24 --- /dev/null +++ b/homeassistant/components/mysensors/strings.json @@ -0,0 +1,79 @@ +{ + "title": "MySensors", + "config": { + "step": { + "user": { + "data": { + "gateway_type": "Gateway type" + }, + "description": "Choose connection method to the gateway" + }, + "gw_tcp": { + "description": "Ethernet gateway setup", + "data": { + "device": "IP address of the gateway", + "tcp_port": "port", + "version": "MySensors version", + "persistence_file": "persistence file (leave empty to auto-generate)" + } + }, + "gw_serial": { + "description": "Serial gateway setup", + "data": { + "device": "Serial port", + "baud_rate": "baud rate", + "version": "MySensors version", + "persistence_file": "persistence file (leave empty to auto-generate)" + } + }, + "gw_mqtt": { + "description": "MQTT gateway setup", + "data": { + "retain": "mqtt retain", + "topic_in_prefix": "prefix for input topics (topic_in_prefix)", + "topic_out_prefix": "prefix for output topics (topic_out_prefix)", + "version": "MySensors version", + "persistence_file": "persistence file (leave empty to auto-generate)" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "invalid_ip": "Invalid IP address", + "invalid_serial": "Invalid serial port", + "invalid_device": "Invalid device", + "invalid_version": "Invalid MySensors version", + "not_a_number": "Please enter a number", + "port_out_of_range": "Port number must be at least 1 and at most 65535", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "invalid_ip": "Invalid IP address", + "invalid_serial": "Invalid serial port", + "invalid_device": "Invalid device", + "invalid_version": "Invalid MySensors version", + "not_a_number": "Please enter a number", + "port_out_of_range": "Port number must be at least 1 and at most 65535", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 0da8bfe7030..14911e11090 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -1,4 +1,6 @@ """Support for MySensors switches.""" +from typing import Callable + import voluptuous as vol from homeassistant.components import mysensors @@ -6,7 +8,11 @@ from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -from .const import DOMAIN as MYSENSORS_DOMAIN, SERVICE_SEND_IR_CODE +from . import on_unload +from ...config_entries import ConfigEntry +from ...helpers.dispatcher import async_dispatcher_connect +from ...helpers.typing import HomeAssistantType +from .const import DOMAIN as MYSENSORS_DOMAIN, MYSENSORS_DISCOVERY, SERVICE_SEND_IR_CODE ATTR_IR_CODE = "V_IR_SEND" @@ -15,8 +21,10 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for switches.""" +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, @@ -32,13 +40,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "S_MOISTURE": MySensorsSwitch, "S_WATER_QUALITY": MySensorsSwitch, } - mysensors.setup_mysensors_platform( - hass, - DOMAIN, - discovery_info, - device_class_map, - async_add_entities=async_add_entities, - ) + + async def async_discover(discovery_info): + """Discover and add a MySensors switch.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + device_class_map, + async_add_entities=async_add_entities, + ) async def async_send_ir_code_service(service): """Set IR code as device state attribute.""" @@ -71,15 +82,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= schema=SEND_IR_CODE_SERVICE_SCHEMA, ) + await on_unload( + hass, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), + ) + class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def current_power_w(self): """Return the current power usage in W.""" @@ -96,7 +112,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_ON self.async_write_ha_state() @@ -106,7 +122,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() @@ -137,7 +153,7 @@ class MySensorsIRSwitch(MySensorsSwitch): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = self._ir_code self._values[set_req.V_LIGHT] = STATE_ON @@ -151,7 +167,7 @@ class MySensorsIRSwitch(MySensorsSwitch): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[set_req.V_LIGHT] = STATE_OFF self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/translations/ca.json b/homeassistant/components/mysensors/translations/ca.json new file mode 100644 index 00000000000..844d9e51da1 --- /dev/null +++ b/homeassistant/components/mysensors/translations/ca.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "duplicate_persistence_file": "Fitxer de persist\u00e8ncia ja en \u00fas", + "duplicate_topic": "Topic ja en \u00fas", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_device": "Dispositiu no v\u00e0lid", + "invalid_ip": "Adre\u00e7a IP inv\u00e0lida", + "invalid_persistence_file": "Fitxer de persist\u00e8ncia inv\u00e0lid", + "invalid_port": "N\u00famero de port inv\u00e0lid", + "invalid_publish_topic": "Topic de publicaci\u00f3 inv\u00e0lid", + "invalid_serial": "Port s\u00e8rie inv\u00e0lid", + "invalid_subscribe_topic": "Topic de subscripci\u00f3 inv\u00e0lid", + "invalid_version": "Versi\u00f3 de MySensors inv\u00e0lida", + "not_a_number": "Introdueix un n\u00famero", + "port_out_of_range": "El n\u00famero de port ha d'estar entre 1 i 65535", + "same_topic": "Els topics de publicaci\u00f3 i subscripci\u00f3 son els mateixos", + "unknown": "Error inesperat" + }, + "error": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "duplicate_persistence_file": "Fitxer de persist\u00e8ncia ja en \u00fas", + "duplicate_topic": "Topic ja en \u00fas", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_device": "Dispositiu no v\u00e0lid", + "invalid_ip": "Adre\u00e7a IP inv\u00e0lida", + "invalid_persistence_file": "Fitxer de persist\u00e8ncia inv\u00e0lid", + "invalid_port": "N\u00famero de port inv\u00e0lid", + "invalid_publish_topic": "Topic de publicaci\u00f3 inv\u00e0lid", + "invalid_serial": "Port s\u00e8rie inv\u00e0lid", + "invalid_subscribe_topic": "Topic de subscripci\u00f3 inv\u00e0lid", + "invalid_version": "Versi\u00f3 de MySensors inv\u00e0lida", + "not_a_number": "Introdueix un n\u00famero", + "port_out_of_range": "El n\u00famero de port ha d'estar entre 1 i 65535", + "same_topic": "Els topics de publicaci\u00f3 i subscripci\u00f3 son els mateixos", + "unknown": "Error inesperat" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "fitxer de persist\u00e8ncia (deixa-ho buit per generar-lo autom\u00e0ticament)", + "retain": "retenci\u00f3 mqtt", + "topic_in_prefix": "prefix per als topics d'entrada (topic_in_prefix)", + "topic_out_prefix": "prefix per als topics de sortida (topic_out_prefix)", + "version": "Versi\u00f3 de MySensors" + }, + "description": "Configuraci\u00f3 de passarel\u00b7la MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "Velocitat, en baudis", + "device": "Port s\u00e8rie", + "persistence_file": "fitxer de persist\u00e8ncia (deixa-ho buit per generar-lo autom\u00e0ticament)", + "version": "Versi\u00f3 de MySensors" + }, + "description": "Configuraci\u00f3 de passarel\u00b7la s\u00e8rie" + }, + "gw_tcp": { + "data": { + "device": "Adre\u00e7a IP de la passarel\u00b7la", + "persistence_file": "fitxer de persist\u00e8ncia (deixa-ho buit per generar-lo autom\u00e0ticament)", + "tcp_port": "port", + "version": "Versi\u00f3 de MySensors" + }, + "description": "Configuraci\u00f3 de passarel\u00b7la Ethernet" + }, + "user": { + "data": { + "gateway_type": "Tipus de passarel\u00b7la" + }, + "description": "Tria el m\u00e8tode de connexi\u00f3 a la passarel\u00b7la" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/cs.json b/homeassistant/components/mysensors/translations/cs.json new file mode 100644 index 00000000000..abe47f046ff --- /dev/null +++ b/homeassistant/components/mysensors/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/de.json b/homeassistant/components/mysensors/translations/de.json new file mode 100644 index 00000000000..189226f29d5 --- /dev/null +++ b/homeassistant/components/mysensors/translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/en.json b/homeassistant/components/mysensors/translations/en.json new file mode 100644 index 00000000000..63af85488f0 --- /dev/null +++ b/homeassistant/components/mysensors/translations/en.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "duplicate_persistence_file": "Persistence file already in use", + "duplicate_topic": "Topic already in use", + "invalid_auth": "Invalid authentication", + "invalid_device": "Invalid device", + "invalid_ip": "Invalid IP address", + "invalid_persistence_file": "Invalid persistence file", + "invalid_port": "Invalid port number", + "invalid_publish_topic": "Invalid publish topic", + "invalid_serial": "Invalid serial port", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_version": "Invalid MySensors version", + "not_a_number": "Please enter a number", + "port_out_of_range": "Port number must be at least 1 and at most 65535", + "same_topic": "Subscribe and publish topics are the same", + "unknown": "Unexpected error" + }, + "error": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "duplicate_persistence_file": "Persistence file already in use", + "duplicate_topic": "Topic already in use", + "invalid_auth": "Invalid authentication", + "invalid_device": "Invalid device", + "invalid_ip": "Invalid IP address", + "invalid_persistence_file": "Invalid persistence file", + "invalid_port": "Invalid port number", + "invalid_publish_topic": "Invalid publish topic", + "invalid_serial": "Invalid serial port", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_version": "Invalid MySensors version", + "not_a_number": "Please enter a number", + "port_out_of_range": "Port number must be at least 1 and at most 65535", + "same_topic": "Subscribe and publish topics are the same", + "unknown": "Unexpected error" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "persistence file (leave empty to auto-generate)", + "retain": "mqtt retain", + "topic_in_prefix": "prefix for input topics (topic_in_prefix)", + "topic_out_prefix": "prefix for output topics (topic_out_prefix)", + "version": "MySensors version" + }, + "description": "MQTT gateway setup" + }, + "gw_serial": { + "data": { + "baud_rate": "baud rate", + "device": "Serial port", + "persistence_file": "persistence file (leave empty to auto-generate)", + "version": "MySensors version" + }, + "description": "Serial gateway setup" + }, + "gw_tcp": { + "data": { + "device": "IP address of the gateway", + "persistence_file": "persistence file (leave empty to auto-generate)", + "tcp_port": "port", + "version": "MySensors version" + }, + "description": "Ethernet gateway setup" + }, + "user": { + "data": { + "gateway_type": "Gateway type" + }, + "description": "Choose connection method to the gateway" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/es.json b/homeassistant/components/mysensors/translations/es.json new file mode 100644 index 00000000000..2a4b30910d1 --- /dev/null +++ b/homeassistant/components/mysensors/translations/es.json @@ -0,0 +1,75 @@ +{ + "config": { + "abort": { + "duplicate_persistence_file": "Archivo de persistencia ya en uso", + "duplicate_topic": "Tema ya en uso", + "invalid_device": "Dispositivo no v\u00e1lido", + "invalid_ip": "Direcci\u00f3n IP no v\u00e1lida", + "invalid_persistence_file": "Archivo de persistencia no v\u00e1lido", + "invalid_port": "N\u00famero de puerto no v\u00e1lido", + "invalid_publish_topic": "Tema de publicaci\u00f3n no v\u00e1lido", + "invalid_serial": "Puerto serie no v\u00e1lido", + "invalid_subscribe_topic": "Tema de suscripci\u00f3n no v\u00e1lido", + "invalid_version": "Versi\u00f3n inv\u00e1lida de MySensors", + "not_a_number": "Por favor, introduzca un n\u00famero", + "port_out_of_range": "El n\u00famero de puerto debe ser como m\u00ednimo 1 y como m\u00e1ximo 65535", + "same_topic": "Los temas de suscripci\u00f3n y publicaci\u00f3n son los mismos" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "duplicate_persistence_file": "Archivo de persistencia ya en uso", + "duplicate_topic": "Tema ya en uso", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_device": "Dispositivo no v\u00e1lido", + "invalid_ip": "Direcci\u00f3n IP no v\u00e1lida", + "invalid_persistence_file": "Archivo de persistencia no v\u00e1lido", + "invalid_port": "N\u00famero de puerto no v\u00e1lido", + "invalid_publish_topic": "Tema de publicaci\u00f3n no v\u00e1lido", + "invalid_serial": "Puerto serie no v\u00e1lido", + "invalid_subscribe_topic": "Tema de suscripci\u00f3n no v\u00e1lido", + "invalid_version": "Versi\u00f3n no v\u00e1lida de MySensors", + "not_a_number": "Por favor, introduce un n\u00famero", + "port_out_of_range": "El n\u00famero de puerto debe ser como m\u00ednimo 1 y como m\u00e1ximo 65535", + "same_topic": "Los temas de suscripci\u00f3n y publicaci\u00f3n son los mismos", + "unknown": "Error inesperado" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "archivo de persistencia (d\u00e9jelo vac\u00edo para que se genere autom\u00e1ticamente)", + "retain": "retener mqtt", + "topic_in_prefix": "prefijo para los temas de entrada (topic_in_prefix)", + "topic_out_prefix": "prefijo para los temas de salida (topic_out_prefix)", + "version": "Versi\u00f3n de MySensors" + }, + "description": "Configuraci\u00f3n del gateway MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "tasa de baudios", + "device": "Puerto serie", + "persistence_file": "archivo de persistencia (d\u00e9jelo vac\u00edo para que se genere autom\u00e1ticamente)", + "version": "Versi\u00f3n de MySensors" + }, + "description": "Configuraci\u00f3n de la pasarela en serie" + }, + "gw_tcp": { + "data": { + "device": "Direcci\u00f3n IP de la pasarela", + "persistence_file": "archivo de persistencia (d\u00e9jelo vac\u00edo para que se genere autom\u00e1ticamente)", + "tcp_port": "Puerto", + "version": "Versi\u00f3n de MySensores" + }, + "description": "Configuraci\u00f3n de la pasarela Ethernet" + }, + "user": { + "data": { + "gateway_type": "Tipo de pasarela" + }, + "description": "Elija el m\u00e9todo de conexi\u00f3n con la pasarela" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/et.json b/homeassistant/components/mysensors/translations/et.json new file mode 100644 index 00000000000..0682610be97 --- /dev/null +++ b/homeassistant/components/mysensors/translations/et.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "duplicate_persistence_file": "P\u00fcsivusfail on juba kasutusel", + "duplicate_topic": "Teema on juba kasutusel", + "invalid_auth": "Vigane autentimine", + "invalid_device": "Sobimatu seade", + "invalid_ip": "Sobimatu IP-aadress", + "invalid_persistence_file": "Sobimatu p\u00fcsivusfail", + "invalid_port": "Lubamatu pordinumber", + "invalid_publish_topic": "Kehtetu avaldamisteema", + "invalid_serial": "Sobimatu jadaport", + "invalid_subscribe_topic": "Kehtetu tellimisteema", + "invalid_version": "Sobimatu MySensors versioon", + "not_a_number": "Sisesta number", + "port_out_of_range": "Pordi number peab olema v\u00e4hemalt 1 ja k\u00f5ige rohkem 65535", + "same_topic": "Tellimise ja avaldamise teemad kattuvad", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "duplicate_persistence_file": "P\u00fcsivusfail on juba kasutusel", + "duplicate_topic": "Teema on juba kasutusel", + "invalid_auth": "Vigane autentimine", + "invalid_device": "Sobimatu seade", + "invalid_ip": "Sobimatu IP-aadress", + "invalid_persistence_file": "Sobimatu p\u00fcsivusfail", + "invalid_port": "Lubamatu pordinumber", + "invalid_publish_topic": "Kehtetu avaldamisteema", + "invalid_serial": "Sobimatu jadaport", + "invalid_subscribe_topic": "Kehtetu tellimisteema", + "invalid_version": "Sobimatu MySensors versioon", + "not_a_number": "Sisesta number", + "port_out_of_range": "Pordi number peab olema v\u00e4hemalt 1 ja k\u00f5ige rohkem 65535", + "same_topic": "Tellimise ja avaldamise teemad kattuvad", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "p\u00fcsivusfail (j\u00e4ta automaatse genereerimise jaoks t\u00fchjaks)", + "retain": "mqtt oleku s\u00e4ilitamine", + "topic_in_prefix": "sisendteemade eesliide (topic_in_prefix)", + "topic_out_prefix": "v\u00e4ljunditeemade eesliide (topic_out_prefix)", + "version": "MySensors versioon" + }, + "description": "MQTT-l\u00fc\u00fcsi seadistamine" + }, + "gw_serial": { + "data": { + "baud_rate": "andmeedastuskiirus", + "device": "Jadaport", + "persistence_file": "p\u00fcsivusfail (j\u00e4ta automaatse genereerimise jaoks t\u00fchjaks)", + "version": "MySensors versioon" + }, + "description": "Jadal\u00fc\u00fcsi h\u00e4\u00e4lestus" + }, + "gw_tcp": { + "data": { + "device": "L\u00fc\u00fcsi IP-aadress", + "persistence_file": "p\u00fcsivusfail (j\u00e4ta automaatse genereerimise jaoks t\u00fchjaks)", + "tcp_port": "port", + "version": "MySensors versioon" + }, + "description": "Etherneti l\u00fc\u00fcsi seadistamine" + }, + "user": { + "data": { + "gateway_type": "L\u00fc\u00fcsi t\u00fc\u00fcp" + }, + "description": "Vali l\u00fc\u00fcsi \u00fchendusviis" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/fr.json b/homeassistant/components/mysensors/translations/fr.json new file mode 100644 index 00000000000..00f9831c035 --- /dev/null +++ b/homeassistant/components/mysensors/translations/fr.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "duplicate_persistence_file": "Fichier de persistance d\u00e9j\u00e0 utilis\u00e9", + "duplicate_topic": "Sujet d\u00e9j\u00e0 utilis\u00e9", + "invalid_auth": "Authentification invalide", + "invalid_device": "Appareil non valide", + "invalid_ip": "Adresse IP non valide", + "invalid_persistence_file": "Fichier de persistance non valide", + "invalid_port": "Num\u00e9ro de port non valide", + "invalid_publish_topic": "Sujet de publication non valide", + "invalid_serial": "Port s\u00e9rie non valide", + "invalid_subscribe_topic": "Sujet d'abonnement non valide", + "invalid_version": "Version de MySensors non valide", + "not_a_number": "Veuillez saisir un nombre", + "port_out_of_range": "Le num\u00e9ro de port doit \u00eatre au moins 1 et au plus 65535", + "same_topic": "Les sujets de souscription et de publication sont identiques", + "unknown": "Erreur inattendue" + }, + "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "duplicate_persistence_file": "Fichier de persistance d\u00e9j\u00e0 utilis\u00e9", + "duplicate_topic": "Sujet d\u00e9j\u00e0 utilis\u00e9", + "invalid_auth": "Authentification invalide", + "invalid_device": "Appareil non valide", + "invalid_ip": "Adresse IP non valide", + "invalid_persistence_file": "Fichier de persistance non valide", + "invalid_port": "Num\u00e9ro de port non valide", + "invalid_publish_topic": "Sujet de publication non valide", + "invalid_serial": "Port s\u00e9rie non valide", + "invalid_subscribe_topic": "Sujet d'abonnement non valide", + "invalid_version": "Version de MySensors non valide", + "not_a_number": "Veuillez saisir un nombre", + "port_out_of_range": "Le num\u00e9ro de port doit \u00eatre au moins 1 et au plus 65535", + "same_topic": "Les sujets de souscription et de publication sont identiques", + "unknown": "Erreur inattendue" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "fichier de persistance (laissez vide pour g\u00e9n\u00e9rer automatiquement)", + "retain": "mqtt conserver", + "topic_in_prefix": "pr\u00e9fixe pour les sujets d\u2019entr\u00e9e (topic_in_prefix)", + "topic_out_prefix": "pr\u00e9fixe pour les sujets de sortie (topic_out_prefix)", + "version": "Version de MySensors" + }, + "description": "Configuration de la passerelle MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "d\u00e9bit en bauds", + "device": "Port s\u00e9rie", + "persistence_file": "fichier de persistance (laissez vide pour g\u00e9n\u00e9rer automatiquement)", + "version": "Version de MySensors" + }, + "description": "Configuration de la passerelle s\u00e9rie" + }, + "gw_tcp": { + "data": { + "device": "Adresse IP de la passerelle", + "persistence_file": "fichier de persistance (laisser vide pour g\u00e9n\u00e9rer automatiquement)", + "tcp_port": "port", + "version": "Version de MySensors" + }, + "description": "Configuration de la passerelle Ethernet" + }, + "user": { + "data": { + "gateway_type": "Type de passerelle" + }, + "description": "Choisissez la m\u00e9thode de connexion \u00e0 la passerelle" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/it.json b/homeassistant/components/mysensors/translations/it.json new file mode 100644 index 00000000000..f256ddb95eb --- /dev/null +++ b/homeassistant/components/mysensors/translations/it.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "duplicate_persistence_file": "File di persistenza gi\u00e0 in uso", + "duplicate_topic": "Argomento gi\u00e0 in uso", + "invalid_auth": "Autenticazione non valida", + "invalid_device": "Dispositivo non valido", + "invalid_ip": "Indirizzo IP non valido", + "invalid_persistence_file": "File di persistenza non valido", + "invalid_port": "Numero di porta non valido", + "invalid_publish_topic": "Argomento di pubblicazione non valido", + "invalid_serial": "Porta seriale non valida", + "invalid_subscribe_topic": "Argomento di sottoscrizione non valido", + "invalid_version": "Versione di MySensors non valida", + "not_a_number": "Per favore inserisci un numero", + "port_out_of_range": "Il numero di porta deve essere almeno 1 e al massimo 65535", + "same_topic": "Gli argomenti di sottoscrizione e pubblicazione sono gli stessi", + "unknown": "Errore imprevisto" + }, + "error": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "duplicate_persistence_file": "File di persistenza gi\u00e0 in uso", + "duplicate_topic": "Argomento gi\u00e0 in uso", + "invalid_auth": "Autenticazione non valida", + "invalid_device": "Dispositivo non valido", + "invalid_ip": "Indirizzo IP non valido", + "invalid_persistence_file": "File di persistenza non valido", + "invalid_port": "Numero di porta non valido", + "invalid_publish_topic": "Argomento di pubblicazione non valido", + "invalid_serial": "Porta seriale non valida", + "invalid_subscribe_topic": "Argomento di sottoscrizione non valido", + "invalid_version": "Versione di MySensors non valida", + "not_a_number": "Per favore inserisci un numero", + "port_out_of_range": "Il numero di porta deve essere almeno 1 e al massimo 65535", + "same_topic": "Gli argomenti di sottoscrizione e pubblicazione sono gli stessi", + "unknown": "Errore imprevisto" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "file di persistenza (lasciare vuoto per generare automaticamente)", + "retain": "mqtt conserva", + "topic_in_prefix": "prefisso per argomenti di input (topic_in_prefix)", + "topic_out_prefix": "prefisso per argomenti di output (topic_out_prefix)", + "version": "Versione MySensors" + }, + "description": "Configurazione del gateway MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "velocit\u00e0 di trasmissione", + "device": "Porta seriale", + "persistence_file": "file di persistenza (lasciare vuoto per generare automaticamente)", + "version": "Versione MySensors" + }, + "description": "Configurazione del gateway seriale" + }, + "gw_tcp": { + "data": { + "device": "Indirizzo IP del gateway", + "persistence_file": "file di persistenza (lasciare vuoto per generare automaticamente)", + "tcp_port": "porta", + "version": "Versione MySensors" + }, + "description": "Configurazione del gateway Ethernet" + }, + "user": { + "data": { + "gateway_type": "Tipo di gateway" + }, + "description": "Scegli il metodo di connessione al gateway" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/ko.json b/homeassistant/components/mysensors/translations/ko.json new file mode 100644 index 00000000000..bb38f94bc92 --- /dev/null +++ b/homeassistant/components/mysensors/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/nl.json b/homeassistant/components/mysensors/translations/nl.json new file mode 100644 index 00000000000..e41f67c7730 --- /dev/null +++ b/homeassistant/components/mysensors/translations/nl.json @@ -0,0 +1,76 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "duplicate_topic": "Topic is al in gebruik", + "invalid_auth": "Ongeldige authenticatie", + "invalid_device": "Ongeldig apparaat", + "invalid_ip": "Ongeldig IP-adres", + "invalid_port": "Ongeldig poortnummer", + "invalid_publish_topic": "Ongeldig publiceer topic", + "invalid_serial": "Ongeldige seri\u00eble poort", + "invalid_version": "Ongeldige MySensors-versie", + "not_a_number": "Voer een nummer in", + "port_out_of_range": "Poortnummer moet minimaal 1 en maximaal 65535 zijn", + "same_topic": "De topics abonneren en publiceren zijn hetzelfde", + "unknown": "Onverwachte fout" + }, + "error": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "duplicate_persistence_file": "Persistentiebestand al in gebruik", + "duplicate_topic": "Topic is al in gebruik", + "invalid_auth": "Ongeldige authenticatie", + "invalid_device": "Ongeldig apparaat", + "invalid_ip": "Ongeldig IP-adres", + "invalid_persistence_file": "Ongeldig persistentiebestand", + "invalid_port": "Ongeldig poortnummer", + "invalid_publish_topic": "ongeldig publiceer topic", + "invalid_serial": "Ongeldige seri\u00eble poort", + "invalid_subscribe_topic": "Ongeldig abonneer topic", + "invalid_version": "Ongeldige MySensors-versie", + "not_a_number": "Voer een nummer in", + "port_out_of_range": "Poortnummer moet minimaal 1 en maximaal 65535 zijn", + "same_topic": "De topics abonneren en publiceren zijn hetzelfde", + "unknown": "Onverwachte fout" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "persistentiebestand (leeg laten om automatisch te genereren)", + "retain": "mqtt behouden", + "topic_in_prefix": "prefix voor inkomende topics (topic_in_prefix)", + "topic_out_prefix": "prefix voor uitgaande topics (topic_out_prefix)", + "version": "MySensors-versie" + }, + "description": "MQTT-gateway instellen" + }, + "gw_serial": { + "data": { + "baud_rate": "baudrate", + "device": "Seri\u00eble poort", + "persistence_file": "persistentiebestand (leeg laten om automatisch te genereren)", + "version": "MySensors-versie" + }, + "description": "Seri\u00eble gateway setup" + }, + "gw_tcp": { + "data": { + "device": "IP-adres van de gateway", + "persistence_file": "persistentiebestand (leeg laten om automatisch te genereren)", + "tcp_port": "Poort", + "version": "MySensors-versie" + }, + "description": "Ethernet gateway instellen" + }, + "user": { + "data": { + "gateway_type": "Gateway type" + }, + "description": "Kies de verbindingsmethode met de gateway" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/no.json b/homeassistant/components/mysensors/translations/no.json new file mode 100644 index 00000000000..9d028260a76 --- /dev/null +++ b/homeassistant/components/mysensors/translations/no.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "duplicate_persistence_file": "Persistensfil allerede i bruk", + "duplicate_topic": "Emnet er allerede i bruk", + "invalid_auth": "Ugyldig godkjenning", + "invalid_device": "Ugyldig enhet", + "invalid_ip": "Ugyldig IP-adresse", + "invalid_persistence_file": "Ugyldig utholdenhetsfil", + "invalid_port": "Ugyldig portnummer", + "invalid_publish_topic": "Ugyldig publiseringsemne", + "invalid_serial": "Ugyldig serieport", + "invalid_subscribe_topic": "Ugyldig abonnementsemne", + "invalid_version": "Ugyldig MySensors-versjon", + "not_a_number": "Vennligst skriv inn et nummer", + "port_out_of_range": "Portnummer m\u00e5 v\u00e6re minst 1 og maksimalt 65535", + "same_topic": "Abonner og publiser emner er de samme", + "unknown": "Uventet feil" + }, + "error": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "duplicate_persistence_file": "Persistensfil allerede i bruk", + "duplicate_topic": "Emnet er allerede i bruk", + "invalid_auth": "Ugyldig godkjenning", + "invalid_device": "Ugyldig enhet", + "invalid_ip": "Ugyldig IP-adresse", + "invalid_persistence_file": "Ugyldig utholdenhetsfil", + "invalid_port": "Ugyldig portnummer", + "invalid_publish_topic": "Ugyldig publiseringsemne", + "invalid_serial": "Ugyldig serieport", + "invalid_subscribe_topic": "Ugyldig abonnementsemne", + "invalid_version": "Ugyldig MySensors-versjon", + "not_a_number": "Vennligst skriv inn et nummer", + "port_out_of_range": "Portnummer m\u00e5 v\u00e6re minst 1 og maksimalt 65535", + "same_topic": "Abonner og publiser emner er de samme", + "unknown": "Uventet feil" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "Persistensfil (la den v\u00e6re tom for automatisk generering)", + "retain": "mqtt beholde", + "topic_in_prefix": "prefiks for input-emner (topic_in_prefix)", + "topic_out_prefix": "prefiks for utgangstemaer (topic_out_prefix)", + "version": "MySensors versjon" + }, + "description": "MQTT gateway-oppsett" + }, + "gw_serial": { + "data": { + "baud_rate": "Overf\u00f8ringshastighet", + "device": "Seriell port", + "persistence_file": "persistensfil (la den v\u00e6re tom for automatisk generering)", + "version": "MySensors versjon" + }, + "description": "Seriell gatewayoppsett" + }, + "gw_tcp": { + "data": { + "device": "IP-adressen til gatewayen", + "persistence_file": "persistensfil (la den v\u00e6re tom for automatisk generering)", + "tcp_port": "Port", + "version": "MySensors versjon" + }, + "description": "Ethernet gateway-oppsett" + }, + "user": { + "data": { + "gateway_type": "" + }, + "description": "Velg tilkoblingsmetode til gatewayen" + } + } + }, + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/pl.json b/homeassistant/components/mysensors/translations/pl.json new file mode 100644 index 00000000000..fa67ffe4030 --- /dev/null +++ b/homeassistant/components/mysensors/translations/pl.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "duplicate_persistence_file": "Plik danych z sensora jest ju\u017c w u\u017cyciu", + "duplicate_topic": "Temat jest ju\u017c w u\u017cyciu", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "invalid_device": "Nieprawid\u0142owe urz\u0105dzenie", + "invalid_ip": "Nieprawid\u0142owy adres IP", + "invalid_persistence_file": "Nieprawid\u0142owy plik danych z sensora", + "invalid_port": "Nieprawid\u0142owy numer portu", + "invalid_publish_topic": "Nieprawid\u0142owy temat \"publish\"", + "invalid_serial": "Nieprawid\u0142owy port szeregowy", + "invalid_subscribe_topic": "Nieprawid\u0142owy temat \"subscribe\"", + "invalid_version": "Nieprawid\u0142owa wersja MySensors", + "not_a_number": "Prosz\u0119 wpisa\u0107 numer", + "port_out_of_range": "Numer portu musi by\u0107 pomi\u0119dzy 1 a 65535", + "same_topic": "Tematy \"subscribe\" i \"publish\" s\u0105 takie same", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "duplicate_persistence_file": "Plik danych z sensora jest ju\u017c w u\u017cyciu", + "duplicate_topic": "Temat jest ju\u017c w u\u017cyciu", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "invalid_device": "Nieprawid\u0142owe urz\u0105dzenie", + "invalid_ip": "Nieprawid\u0142owy adres IP", + "invalid_persistence_file": "Nieprawid\u0142owy plik danych z sensora", + "invalid_port": "Nieprawid\u0142owy numer portu", + "invalid_publish_topic": "Nieprawid\u0142owy temat \"publish\"", + "invalid_serial": "Nieprawid\u0142owy port szeregowy", + "invalid_subscribe_topic": "Nieprawid\u0142owy temat \"subscribe\"", + "invalid_version": "Nieprawid\u0142owa wersja MySensors", + "not_a_number": "Prosz\u0119 wpisa\u0107 numer", + "port_out_of_range": "Numer portu musi by\u0107 pomi\u0119dzy 1 a 65535", + "same_topic": "Tematy \"subscribe\" i \"publish\" s\u0105 takie same", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "plik danych z sensora (pozostaw puste. aby wygenerowa\u0107 automatycznie)", + "retain": "flaga \"retain\" dla mqtt", + "topic_in_prefix": "prefix tematu wej\u015bciowego (topic_in_prefix)", + "topic_out_prefix": "prefix tematu wyj\u015bciowego (topic_out_prefix)", + "version": "Wersja MySensors" + }, + "description": "Konfiguracja bramki MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "szybko\u015b\u0107 transmisji (baud rate)", + "device": "Port szeregowy", + "persistence_file": "plik danych z sensora (pozostaw puste. aby wygenerowa\u0107 automatycznie)", + "version": "Wersja MySensors" + }, + "description": "Konfiguracja bramki szeregowej" + }, + "gw_tcp": { + "data": { + "device": "Adres IP bramki", + "persistence_file": "plik danych z sensora (pozostaw puste. aby wygenerowa\u0107 automatycznie)", + "tcp_port": "port", + "version": "Wersja MySensors" + }, + "description": "Konfiguracja bramki LAN" + }, + "user": { + "data": { + "gateway_type": "Typ bramki" + }, + "description": "Wybierz metod\u0119 po\u0142\u0105czenia z bramk\u0105" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/ru.json b/homeassistant/components/mysensors/translations/ru.json new file mode 100644 index 00000000000..62679709017 --- /dev/null +++ b/homeassistant/components/mysensors/translations/ru.json @@ -0,0 +1,79 @@ +{ + "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.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "duplicate_persistence_file": "\u042d\u0442\u043e\u0442 \u0444\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f.", + "duplicate_topic": "\u042d\u0442\u043e\u0442 \u0442\u043e\u043f\u0438\u043a \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\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_device": "\u041d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "invalid_ip": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441.", + "invalid_persistence_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0444\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439.", + "invalid_port": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430.", + "invalid_publish_topic": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u0434\u043b\u044f \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_serial": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442.", + "invalid_subscribe_topic": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u0434\u043b\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438.", + "invalid_version": "\u041d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f MySensors.", + "not_a_number": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0447\u0438\u0441\u043b\u043e.", + "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u043e\u0442 1 \u0434\u043e 65535.", + "same_topic": "\u0422\u043e\u043f\u0438\u043a\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438 \u0438 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0430\u044e\u0442.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "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.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "duplicate_persistence_file": "\u042d\u0442\u043e\u0442 \u0444\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f.", + "duplicate_topic": "\u042d\u0442\u043e\u0442 \u0442\u043e\u043f\u0438\u043a \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\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_device": "\u041d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "invalid_ip": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441.", + "invalid_persistence_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0444\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439.", + "invalid_port": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430.", + "invalid_publish_topic": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u0434\u043b\u044f \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_serial": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442.", + "invalid_subscribe_topic": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u0434\u043b\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438.", + "invalid_version": "\u041d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f MySensors.", + "not_a_number": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0447\u0438\u0441\u043b\u043e.", + "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u043e\u0442 1 \u0434\u043e 65535.", + "same_topic": "\u0422\u043e\u043f\u0438\u043a\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438 \u0438 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0430\u044e\u0442.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "\u0424\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f)", + "retain": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f MQTT", + "topic_in_prefix": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0442\u043e\u043f\u0438\u043a\u043e\u0432 (topic_in_prefix)", + "topic_out_prefix": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0442\u043e\u043f\u0438\u043a\u043e\u0432 (topic_out_prefix)", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f MySensors" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 MQTT" + }, + "gw_serial": { + "data": { + "baud_rate": "\u0421\u043a\u043e\u0440\u043e\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0434\u0430\u043d\u043d\u044b\u0445", + "device": "\u041f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442", + "persistence_file": "\u0424\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f)", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f MySensors" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0448\u043b\u044e\u0437\u0430" + }, + "gw_tcp": { + "data": { + "device": "IP-\u0430\u0434\u0440\u0435\u0441 \u0448\u043b\u044e\u0437\u0430", + "persistence_file": "\u0424\u0430\u0439\u043b \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0439 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f)", + "tcp_port": "\u041f\u043e\u0440\u0442", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f MySensors" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 Ethernet" + }, + "user": { + "data": { + "gateway_type": "\u0422\u0438\u043f \u0448\u043b\u044e\u0437\u0430" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0448\u043b\u044e\u0437\u0443" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/zh-Hant.json b/homeassistant/components/mysensors/translations/zh-Hant.json new file mode 100644 index 00000000000..d0067c2d0ce --- /dev/null +++ b/homeassistant/components/mysensors/translations/zh-Hant.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "duplicate_persistence_file": "Persistence \u6a94\u6848\u5df2\u4f7f\u7528\u4e2d", + "duplicate_topic": "\u4e3b\u984c\u5df2\u4f7f\u7528\u4e2d", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_device": "\u88dd\u7f6e\u7121\u6548", + "invalid_ip": "IP \u4f4d\u5740\u7121\u6548", + "invalid_persistence_file": "Persistence \u6a94\u6848\u7121\u6548", + "invalid_port": "\u901a\u8a0a\u57e0\u865f\u78bc\u7121\u6548", + "invalid_publish_topic": "\u767c\u5e03\u4e3b\u984c\u7121\u6548", + "invalid_serial": "\u5e8f\u5217\u57e0\u7121\u6548", + "invalid_subscribe_topic": "\u8a02\u95b1\u4e3b\u984c\u7121\u6548", + "invalid_version": "MySensors \u7248\u672c\u7121\u6548", + "not_a_number": "\u8acb\u8f38\u5165\u865f\u78bc", + "port_out_of_range": "\u8acb\u8f38\u5165\u4ecb\u65bc 1 \u81f3 65535 \u4e4b\u9593\u7684\u865f\u78bc", + "same_topic": "\u8a02\u95b1\u8207\u767c\u4f48\u4e3b\u984c\u76f8\u540c", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "duplicate_persistence_file": "Persistence \u6a94\u6848\u5df2\u4f7f\u7528\u4e2d", + "duplicate_topic": "\u4e3b\u984c\u5df2\u4f7f\u7528\u4e2d", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_device": "\u88dd\u7f6e\u7121\u6548", + "invalid_ip": "IP \u4f4d\u5740\u7121\u6548", + "invalid_persistence_file": "Persistence \u6a94\u6848\u7121\u6548", + "invalid_port": "\u901a\u8a0a\u57e0\u865f\u78bc\u7121\u6548", + "invalid_publish_topic": "\u767c\u5e03\u4e3b\u984c\u7121\u6548", + "invalid_serial": "\u5e8f\u5217\u57e0\u7121\u6548", + "invalid_subscribe_topic": "\u8a02\u95b1\u4e3b\u984c\u7121\u6548", + "invalid_version": "MySensors \u7248\u672c\u7121\u6548", + "not_a_number": "\u8acb\u8f38\u5165\u865f\u78bc", + "port_out_of_range": "\u8acb\u8f38\u5165\u4ecb\u65bc 1 \u81f3 65535 \u4e4b\u9593\u7684\u865f\u78bc", + "same_topic": "\u8a02\u95b1\u8207\u767c\u4f48\u4e3b\u984c\u76f8\u540c", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "Persistence \u6a94\u6848\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u81ea\u52d5\u7522\u751f\uff09", + "retain": "mqtt retain", + "topic_in_prefix": "\u8f38\u5165\u4e3b\u984c\u524d\u7db4\uff08topic_in_prefix\uff09", + "topic_out_prefix": "\u8f38\u51fa\u4e3b\u984c\u524d\u7db4\uff08topic_out_prefix\uff09", + "version": "MySensors \u7248\u672c" + }, + "description": "MQTT \u9598\u9053\u5668\u8a2d\u5b9a" + }, + "gw_serial": { + "data": { + "baud_rate": "\u50b3\u8f38\u7387", + "device": "\u5e8f\u5217\u57e0", + "persistence_file": "Persistence \u6a94\u6848\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u81ea\u52d5\u7522\u751f\uff09", + "version": "MySensors \u7248\u672c" + }, + "description": "\u9598\u9053\u5668\u8a0a\u5217\u57e0\u8a2d\u5b9a" + }, + "gw_tcp": { + "data": { + "device": "\u7db2\u95dc IP \u4f4d\u5740", + "persistence_file": "Persistence \u6a94\u6848\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u81ea\u52d5\u7522\u751f\uff09", + "tcp_port": "\u901a\u8a0a\u57e0", + "version": "MySensors \u7248\u672c" + }, + "description": "\u9598\u9053\u5668\u4e59\u592a\u7db2\u8def\u8a2d\u5b9a" + }, + "user": { + "data": { + "gateway_type": "\u9598\u9053\u5668\u985e\u578b" + }, + "description": "\u9078\u64c7\u9598\u9053\u5668\u9023\u7dda\u65b9\u5f0f" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/neato/api.py b/homeassistant/components/neato/api.py index 931d7cdb712..31988fc175e 100644 --- a/homeassistant/components/neato/api.py +++ b/homeassistant/components/neato/api.py @@ -1,14 +1,11 @@ """API for Neato Botvac bound to Home Assistant OAuth.""" from asyncio import run_coroutine_threadsafe -import logging import pybotvac from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -_LOGGER = logging.getLogger(__name__) - class ConfigEntryAuth(pybotvac.OAuthSession): """Provide Neato Botvac authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 449de72b158..1f2f575ae50 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -11,8 +11,6 @@ from homeassistant.helpers import config_entry_oauth2_flow # pylint: disable=unused-import from .const import NEATO_DOMAIN -_LOGGER = logging.getLogger(__name__) - class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN diff --git a/homeassistant/components/neato/translations/fr.json b/homeassistant/components/neato/translations/fr.json index 69f2186c54c..26b97e83c0b 100644 --- a/homeassistant/components/neato/translations/fr.json +++ b/homeassistant/components/neato/translations/fr.json @@ -2,7 +2,11 @@ "config": { "abort": { "already_configured": "D\u00e9j\u00e0 configur\u00e9", - "invalid_auth": "Authentification invalide" + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "invalid_auth": "Authentification invalide", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation ", + "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { "default": "Voir [Documentation Neato]({docs_url})." @@ -12,6 +16,12 @@ "unknown": "Erreur inattendue" }, "step": { + "pick_implementation": { + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + }, + "reauth_confirm": { + "title": "Voulez-vous commencer la configuration ?" + }, "user": { "data": { "password": "Mot de passe", @@ -22,5 +32,6 @@ "title": "Informations compte Neato" } } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/neato/translations/it.json b/homeassistant/components/neato/translations/it.json index 95866e918c6..b559c23bb1a 100644 --- a/homeassistant/components/neato/translations/it.json +++ b/homeassistant/components/neato/translations/it.json @@ -6,7 +6,7 @@ "invalid_auth": "Autenticazione non valida", "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 riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "create_entry": { "default": "Autenticazione riuscita" diff --git a/homeassistant/components/neato/translations/ko.json b/homeassistant/components/neato/translations/ko.json index 00d1ae3b467..359aeefcc78 100644 --- a/homeassistant/components/neato/translations/ko.json +++ b/homeassistant/components/neato/translations/ko.json @@ -1,12 +1,27 @@ { "config": { "abort": { - "already_configured": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "create_entry": { - "default": "[Neato \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + }, + "reauth_confirm": { + "title": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + }, "user": { "data": { "password": "\ube44\ubc00\ubc88\ud638", diff --git a/homeassistant/components/neato/translations/nl.json b/homeassistant/components/neato/translations/nl.json index 26e5a647b1d..563e6500c16 100644 --- a/homeassistant/components/neato/translations/nl.json +++ b/homeassistant/components/neato/translations/nl.json @@ -2,7 +2,11 @@ "config": { "abort": { "already_configured": "Al geconfigureerd", - "invalid_auth": "Ongeldige authenticatie" + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "invalid_auth": "Ongeldige authenticatie", + "missing_configuration": "De Netatmo-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" }, "create_entry": { "default": "Zie [Neato-documentatie] ({docs_url})." @@ -12,6 +16,12 @@ "unknown": "Onverwachte fout" }, "step": { + "pick_implementation": { + "title": "Kies een authenticatie methode" + }, + "reauth_confirm": { + "title": "Wil je beginnen met instellen?" + }, "user": { "data": { "password": "Wachtwoord", @@ -22,5 +32,6 @@ "title": "Neato-account info" } } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/neato/translations/ru.json b/homeassistant/components/neato/translations/ru.json index 30ea15c60c3..ea1be16d7ac 100644 --- a/homeassistant/components/neato/translations/ru.json +++ b/homeassistant/components/neato/translations/ru.json @@ -3,7 +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.", "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_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438.", "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,7 +12,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 85e591707ad..b0abd24012a 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -211,7 +211,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): if DATA_SDM not in entry.data: # Legacy API return True - + _LOGGER.debug("Stopping nest subscriber") subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] subscriber.stop_async() unload_ok = all( diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index aa8e100059a..cc2730fad8a 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -146,6 +146,9 @@ class NestCamera(Camera): # Next attempt to catch a url will get a new one self._stream = None return + # Update the stream worker with the latest valid url + if self.stream: + self.stream.update_source(self._stream.rtsp_stream_url) self._schedule_stream_refresh() async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 6413b2e0dfe..b0c64329ffd 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -75,6 +75,8 @@ FAN_MODE_MAP = { } FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} +MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API + async def async_setup_sdm_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities @@ -322,4 +324,7 @@ class ThermostatEntity(ClimateEntity): if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") trait = self._device.traits[FanTrait.NAME] - await trait.set_timer(FAN_INV_MODE_MAP[fan_mode]) + duration = None + if fan_mode != FAN_OFF: + duration = MAX_FAN_DURATION + await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index e5bd7ea1ca8..ee16ca1166f 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,5 +1,4 @@ """Provides device automations for Nest.""" -import logging from typing import List import voluptuous as vol @@ -17,8 +16,6 @@ from homeassistant.helpers.typing import ConfigType from .const import DATA_SUBSCRIBER, DOMAIN from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT -_LOGGER = logging.getLogger(__name__) - DEVICE = "device" TRIGGER_TYPES = set(DEVICE_TRAIT_TRIGGER_MAP.values()) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f9bc135693d..734261d9b08 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.9"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.12"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [{"macaddress":"18B430*"}] diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 6693c2e5614..f45c9ea489f 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -7,7 +7,7 @@ "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", "reauth_successful": "Re-authentication was successful", "single_instance_allowed": "Already configured. Only a single configuration possible.", - "unknown_authorize_url_generation": "Unknown error generating an authorize url." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL." }, "create_entry": { "default": "Successfully authenticated" diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index 03b55458e9b..2830bf5da87 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -5,6 +5,7 @@ "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown_authorize_url_generation": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation." }, @@ -34,7 +35,19 @@ }, "pick_implementation": { "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + }, + "reauth_confirm": { + "description": "L'int\u00e9gration Nest doit r\u00e9-authentifier votre compte", + "title": "R\u00e9-authentifier l'int\u00e9gration" } } + }, + "device_automation": { + "trigger_type": { + "camera_motion": "Mouvement d\u00e9tect\u00e9", + "camera_person": "Personne d\u00e9tect\u00e9e", + "camera_sound": "Son d\u00e9tect\u00e9", + "doorbell_chime": "Sonnette enfonc\u00e9e" + } } } \ No newline at end of file diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index 376437d20f0..84c04049946 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -5,7 +5,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 riautenticazione ha avuto successo", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", "unknown_authorize_url_generation": "Errore sconosciuto durante la generazione di un URL di autorizzazione." }, @@ -38,7 +38,7 @@ }, "reauth_confirm": { "description": "L'integrazione di Nest deve autenticare nuovamente il tuo account", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" } } }, diff --git a/homeassistant/components/nest/translations/ko.json b/homeassistant/components/nest/translations/ko.json index 798f191e34a..f5a0fcf39d1 100644 --- a/homeassistant/components/nest/translations/ko.json +++ b/homeassistant/components/nest/translations/ko.json @@ -1,20 +1,29 @@ { "config": { "abort": { - "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "authorize_url_fail": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "unknown_authorize_url_generation": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "internal_error": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \ub0b4\ubd80 \uc624\ub958 \ubc1c\uc0dd", + "invalid_pin": "PIN \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04 \ucd08\uacfc", - "unknown": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958 \ubc1c\uc0dd" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "init": { "data": { "flow_impl": "\uacf5\uae09\uc790" }, - "description": "Nest \ub97c \uc778\uc99d\ud558\uae30 \uc704\ud55c \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "description": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30", "title": "\uc778\uc99d \uacf5\uae09\uc790" }, "link": { @@ -23,6 +32,12 @@ }, "description": "Nest \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74, [\uacc4\uc815 \uc5f0\uacb0 \uc2b9\uc778]({url}) \uc744 \ud574\uc8fc\uc138\uc694.\n\n\uc2b9\uc778 \ud6c4, \uc544\ub798\uc758 PIN \ucf54\ub4dc\ub97c \ubcf5\uc0ac\ud558\uc5ec \ubd99\uc5ec\ub123\uc73c\uc138\uc694.", "title": "Nest \uacc4\uc815 \uc5f0\uacb0\ud558\uae30" + }, + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + }, + "reauth_confirm": { + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" } } } diff --git a/homeassistant/components/nest/translations/nl.json b/homeassistant/components/nest/translations/nl.json index 931b8aa770e..387b1effcb0 100644 --- a/homeassistant/components/nest/translations/nl.json +++ b/homeassistant/components/nest/translations/nl.json @@ -2,10 +2,19 @@ "config": { "abort": { "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", - "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url." + "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url.", + "missing_configuration": "De Netatmo-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", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", + "unknown_authorize_url_generation": "Onbekende fout bij het genereren van een autorisatie-URL." + }, + "create_entry": { + "default": "Succesvol geauthenticeerd" }, "error": { "internal_error": "Interne foutvalidatiecode", + "invalid_pin": "Ongeldige PIN-code", "timeout": "Time-out validatie van code", "unknown": "Onbekende foutvalidatiecode" }, @@ -23,6 +32,13 @@ }, "description": "Als je je Nest-account wilt koppelen, [autoriseer je account] ( {url} ). \n\nNa autorisatie, kopieer en plak de voorziene pincode hieronder.", "title": "Koppel Nest-account" + }, + "pick_implementation": { + "title": "Kies een authenticatie methode" + }, + "reauth_confirm": { + "description": "De Nest-integratie moet je 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 dfaf33b3969..14166f7d9a6 100644 --- a/homeassistant/components/nest/translations/no.json +++ b/homeassistant/components/nest/translations/no.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "authorize_url_fail": "Ukjent feil ved generering av godkjenningsadresse", + "authorize_url_fail": "Ukjent feil under generering av en autoriserings-URL.", "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.", - "unknown_authorize_url_generation": "Ukjent feil ved generering av godkjenningsadresse" + "unknown_authorize_url_generation": "Ukjent feil under generering av en autoriserings-URL." }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 67e83189fc5..cdbd34991f2 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -21,6 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import async_call_later from . import api, config_flow from .const import ( @@ -54,18 +59,19 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["camera", "climate", "sensor"] +PLATFORMS = ["camera", "climate", "light", "sensor"] async def async_setup(hass: HomeAssistant, config: dict): """Set up the Netatmo component.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_PERSONS] = {} - hass.data[DOMAIN][DATA_DEVICE_IDS] = {} - hass.data[DOMAIN][DATA_SCHEDULES] = {} - hass.data[DOMAIN][DATA_HOMES] = {} - hass.data[DOMAIN][DATA_EVENTS] = {} - hass.data[DOMAIN][DATA_CAMERAS] = {} + hass.data[DOMAIN] = { + DATA_PERSONS: {}, + DATA_DEVICE_IDS: {}, + DATA_SCHEDULES: {}, + DATA_HOMES: {}, + DATA_EVENTS: {}, + DATA_CAMERAS: {}, + } if DOMAIN not in config: return True @@ -114,6 +120,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if CONF_WEBHOOK_ID not in entry.data: return _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + async_dispatcher_send( + hass, + f"signal-{DOMAIN}-webhook-None", + {"type": "None", "data": {"push_type": "webhook_deactivation"}}, + ) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) async def register_webhook(event): @@ -148,13 +159,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): webhook_register( hass, DOMAIN, "Netatmo", entry.data[CONF_WEBHOOK_ID], handle_webhook ) + + async def handle_event(event): + """Handle webhook events.""" + if event["data"]["push_type"] == "webhook_activation": + if activation_listener is not None: + _LOGGER.debug("sub called") + activation_listener() + + if activation_timeout is not None: + _LOGGER.debug("Unsub called") + activation_timeout() + + activation_listener = async_dispatcher_connect( + hass, + f"signal-{DOMAIN}-webhook-None", + handle_event, + ) + + activation_timeout = async_call_later(hass, 10, unregister_webhook) + await hass.async_add_executor_job( hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url ) _LOGGER.info("Register Netatmo webhook: %s", webhook_url) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") - ) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index dee8d3b668d..a53f7f9fb08 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -26,12 +26,14 @@ from homeassistant.const import ( ) from homeassistant.core import callback 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 .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + DATA_DEVICE_IDS, DATA_HANDLER, DATA_HOMES, DATA_SCHEDULES, @@ -237,6 +239,10 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) ) + registry = await async_get_registry(self.hass) + device = registry.async_get_device({(DOMAIN, self._id)}, set()) + self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id + async def handle_event(self, event): """Handle webhook events.""" data = event["data"] @@ -346,6 +352,9 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" + if self.hvac_mode == HVAC_MODE_OFF: + self.turn_on() + if self.target_temperature == 0: self._home_status.set_room_thermpoint( self._id, @@ -567,6 +576,11 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): schedule_id, ) + @property + def device_info(self): + """Return the device info for the thermostat.""" + return {**super().device_info, "suggested_area": self._room_data["name"]} + def interpolate(batterylevel, module_type): """Interpolate battery level depending on device type.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index ed1c5f0a880..baee3e4035c 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -4,21 +4,36 @@ API = "api" DOMAIN = "netatmo" MANUFACTURER = "Netatmo" +MODEL_NAPLUG = "Relay" +MODEL_NATHERM1 = "Smart Thermostat" +MODEL_NRV = "Smart Radiator Valves" +MODEL_NOC = "Smart Outdoor Camera" +MODEL_NACAMERA = "Smart Indoor Camera" +MODEL_NSD = "Smart Smoke Alarm" +MODEL_NACAMDOORTAG = "Smart Door and Window Sensors" +MODEL_NHC = "Smart Indoor Air Quality Monitor" +MODEL_NAMAIN = "Smart Home Weather station – indoor module" +MODEL_NAMODULE1 = "Smart Home Weather station – outdoor module" +MODEL_NAMODULE4 = "Smart Additional Indoor module" +MODEL_NAMODULE3 = "Smart Rain Gauge" +MODEL_NAMODULE2 = "Smart Anemometer" +MODEL_PUBLIC = "Public Weather stations" + MODELS = { - "NAPlug": "Relay", - "NATherm1": "Smart Thermostat", - "NRV": "Smart Radiator Valves", - "NACamera": "Smart Indoor Camera", - "NOC": "Smart Outdoor Camera", - "NSD": "Smart Smoke Alarm", - "NACamDoorTag": "Smart Door and Window Sensors", - "NHC": "Smart Indoor Air Quality Monitor", - "NAMain": "Smart Home Weather station – indoor module", - "NAModule1": "Smart Home Weather station – outdoor module", - "NAModule4": "Smart Additional Indoor module", - "NAModule3": "Smart Rain Gauge", - "NAModule2": "Smart Anemometer", - "public": "Public Weather stations", + "NAPlug": MODEL_NAPLUG, + "NATherm1": MODEL_NATHERM1, + "NRV": MODEL_NRV, + "NACamera": MODEL_NACAMERA, + "NOC": MODEL_NOC, + "NSD": MODEL_NSD, + "NACamDoorTag": MODEL_NACAMDOORTAG, + "NHC": MODEL_NHC, + "NAMain": MODEL_NAMAIN, + "NAModule1": MODEL_NAMODULE1, + "NAModule4": MODEL_NAMODULE4, + "NAModule3": MODEL_NAMODULE3, + "NAModule2": MODEL_NAMODULE2, + "public": MODEL_PUBLIC, } AUTH = "netatmo_auth" @@ -56,7 +71,6 @@ DEFAULT_PERSON = "Unknown" DEFAULT_DISCOVERY = True DEFAULT_WEBHOOKS = False -ATTR_ID = "id" ATTR_PSEUDO = "pseudo" ATTR_NAME = "name" ATTR_EVENT_TYPE = "event_type" @@ -77,12 +91,66 @@ SERVICE_SET_SCHEDULE = "set_schedule" SERVICE_SET_PERSONS_HOME = "set_persons_home" SERVICE_SET_PERSON_AWAY = "set_person_away" +# Climate events +EVENT_TYPE_SET_POINT = "set_point" EVENT_TYPE_CANCEL_SET_POINT = "cancel_set_point" +EVENT_TYPE_THERM_MODE = "therm_mode" +# Camera events EVENT_TYPE_LIGHT_MODE = "light_mode" +EVENT_TYPE_CAMERA_OUTDOOR = "outdoor" +EVENT_TYPE_CAMERA_ANIMAL = "animal" +EVENT_TYPE_CAMERA_HUMAN = "human" +EVENT_TYPE_CAMERA_VEHICLE = "vehicle" +EVENT_TYPE_CAMERA_MOVEMENT = "movement" +EVENT_TYPE_CAMERA_PERSON = "person" +EVENT_TYPE_CAMERA_PERSON_AWAY = "person_away" +# Door tags +EVENT_TYPE_DOOR_TAG_SMALL_MOVE = "tag_small_move" +EVENT_TYPE_DOOR_TAG_BIG_MOVE = "tag_big_move" +EVENT_TYPE_DOOR_TAG_OPEN = "tag_open" EVENT_TYPE_OFF = "off" EVENT_TYPE_ON = "on" -EVENT_TYPE_SET_POINT = "set_point" -EVENT_TYPE_THERM_MODE = "therm_mode" +EVENT_TYPE_ALARM_STARTED = "alarm_started" + +OUTDOOR_CAMERA_TRIGGERS = [ + EVENT_TYPE_CAMERA_ANIMAL, + EVENT_TYPE_CAMERA_HUMAN, + EVENT_TYPE_CAMERA_OUTDOOR, + EVENT_TYPE_CAMERA_VEHICLE, +] +INDOOR_CAMERA_TRIGGERS = [ + EVENT_TYPE_CAMERA_MOVEMENT, + EVENT_TYPE_CAMERA_PERSON, + EVENT_TYPE_CAMERA_PERSON_AWAY, + EVENT_TYPE_ALARM_STARTED, +] +DOOR_TAG_TRIGGERS = [ + EVENT_TYPE_DOOR_TAG_SMALL_MOVE, + EVENT_TYPE_DOOR_TAG_BIG_MOVE, + EVENT_TYPE_DOOR_TAG_OPEN, +] +CLIMATE_TRIGGERS = [ + EVENT_TYPE_SET_POINT, + EVENT_TYPE_CANCEL_SET_POINT, + EVENT_TYPE_THERM_MODE, +] +EVENT_ID_MAP = { + EVENT_TYPE_CAMERA_MOVEMENT: "device_id", + EVENT_TYPE_CAMERA_PERSON: "device_id", + EVENT_TYPE_CAMERA_PERSON_AWAY: "device_id", + EVENT_TYPE_CAMERA_ANIMAL: "device_id", + EVENT_TYPE_CAMERA_HUMAN: "device_id", + EVENT_TYPE_CAMERA_OUTDOOR: "device_id", + EVENT_TYPE_CAMERA_VEHICLE: "device_id", + EVENT_TYPE_DOOR_TAG_SMALL_MOVE: "device_id", + EVENT_TYPE_DOOR_TAG_BIG_MOVE: "device_id", + EVENT_TYPE_DOOR_TAG_OPEN: "device_id", + EVENT_TYPE_LIGHT_MODE: "device_id", + EVENT_TYPE_ALARM_STARTED: "device_id", + EVENT_TYPE_CANCEL_SET_POINT: "room_id", + EVENT_TYPE_SET_POINT: "room_id", + EVENT_TYPE_THERM_MODE: "home_id", +} MODE_LIGHT_ON = "on" MODE_LIGHT_OFF = "off" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index ae0995639c7..9bc4b197f1b 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -107,6 +107,10 @@ class NetatmoDataHandler: _LOGGER.info("%s webhook successfully registered", MANUFACTURER) self._webhook = True + elif event["data"]["push_type"] == "webhook_deactivation": + _LOGGER.info("%s webhook unregistered", MANUFACTURER) + self._webhook = False + elif event["data"]["push_type"] == "NACamera-connection": _LOGGER.debug("%s camera reconnected", MANUFACTURER) self._data_classes[CAMERA_DATA_CLASS_NAME][NEXT_SCAN] = time() @@ -118,6 +122,7 @@ class NetatmoDataHandler: partial(data_class, **kwargs), self._auth, ) + for update_callback in self._data_classes[data_class_entry][ "subscriptions" ]: diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py new file mode 100644 index 00000000000..38601e981db --- /dev/null +++ b/homeassistant/components/netatmo/device_trigger.py @@ -0,0 +1,155 @@ +"""Provides device automations for Netatmo.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import 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 ( + ATTR_DEVICE_ID, + 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 +from .climate import STATE_NETATMO_AWAY, STATE_NETATMO_HG, STATE_NETATMO_SCHEDULE +from .const import ( + CLIMATE_TRIGGERS, + EVENT_TYPE_THERM_MODE, + INDOOR_CAMERA_TRIGGERS, + MODEL_NACAMERA, + MODEL_NATHERM1, + MODEL_NOC, + MODEL_NRV, + NETATMO_EVENT, + OUTDOOR_CAMERA_TRIGGERS, +) + +CONF_SUBTYPE = "subtype" + +DEVICES = { + MODEL_NACAMERA: INDOOR_CAMERA_TRIGGERS, + MODEL_NOC: OUTDOOR_CAMERA_TRIGGERS, + MODEL_NATHERM1: CLIMATE_TRIGGERS, + MODEL_NRV: CLIMATE_TRIGGERS, +} + +SUBTYPES = { + EVENT_TYPE_THERM_MODE: [ + STATE_NETATMO_SCHEDULE, + STATE_NETATMO_HG, + STATE_NETATMO_AWAY, + ] +} + +TRIGGER_TYPES = OUTDOOR_CAMERA_TRIGGERS + INDOOR_CAMERA_TRIGGERS + CLIMATE_TRIGGERS + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + vol.Optional(CONF_SUBTYPE): str, + } +) + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = config[CONF_TYPE] + + if ( + not device + or device.model not in DEVICES + or trigger not in DEVICES[device.model] + ): + raise InvalidDeviceAutomationConfig(f"Unsupported model {device.model}") + + return config + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Netatmo devices.""" + registry = await entity_registry.async_get_registry(hass) + device_registry = await hass.helpers.device_registry.async_get_registry() + triggers = [] + + for entry in entity_registry.async_entries_for_device(registry, device_id): + device = device_registry.async_get(device_id) + + for trigger in DEVICES.get(device.model, []): + if trigger in SUBTYPES: + for subtype in SUBTYPES[trigger]: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + else: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: trigger, + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + if not device: + return + + if device.model not in DEVICES: + return + + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: NETATMO_EVENT, + event_trigger.CONF_EVENT_DATA: { + "type": config[CONF_TYPE], + ATTR_DEVICE_ID: config[ATTR_DEVICE_ID], + }, + } + if config[CONF_TYPE] in SUBTYPES: + event_config[event_trigger.CONF_EVENT_DATA]["data"] = { + "mode": config[CONF_SUBTYPE] + } + + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 0c9e0c53176..dc8bf3f1fc8 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -53,9 +53,6 @@ async def async_setup_entry(hass, entry, async_add_entities): for camera in all_cameras: if camera["type"] == "NOC": - if not data_handler.webhook: - raise PlatformNotReady - _LOGGER.debug("Adding camera light %s %s", camera["id"], camera["name"]) entities.append( NetatmoLight( @@ -126,6 +123,11 @@ class NetatmoLight(NetatmoBase, LightEntity): self.async_write_ha_state() return + @property + def available(self) -> bool: + """If the webhook is not established, mark as unavailable.""" + return bool(self.data_handler.webhook) + @property def is_on(self): """Return true if light is on.""" diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index d0753613555..1845cbe76e9 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -5,7 +5,7 @@ from typing import Dict, List from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.entity import Entity -from .const import DOMAIN, MANUFACTURER, MODELS, SIGNAL_NAME +from .const import DATA_DEVICE_IDS, DOMAIN, MANUFACTURER, MODELS, SIGNAL_NAME from .data_handler import NetatmoDataHandler _LOGGER = logging.getLogger(__name__) @@ -58,6 +58,10 @@ 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()) + self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._id] = device.id + self.async_update_callback() async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 459ef23b0e0..06f56d084c6 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -1,46 +1,79 @@ # Describes the format for available Netatmo services set_camera_light: - description: Set the camera light mode. + name: Set camera light mode + description: Sets the light mode for a Netatmo Outdoor camera light. + target: + entity: + integration: netatmo + domain: light fields: camera_light_mode: + name: Camera light mode description: Outdoor camera light mode (on/off/auto) example: auto - entity_id: - description: Entity id of the camera. - example: camera.netatmo_entrance + required: true + selector: + select: + options: + - "on" + - "off" + - "auto" set_schedule: - description: Set the heating schedule. + name: Set heating schedule + description: + Set the heating schedule for Netatmo climate device. The schedule name must + match a schedule configured at Netatmo. + target: + entity: + integration: netatmo + domain: climate fields: schedule_name: description: Schedule name example: Standard - entity_id: - description: Entity id of the climate device. - example: climate.netatmo_livingroom + required: true + selector: + text: set_persons_home: - description: Set a list of persons as at home. Person's name must match a name known by the Welcome Camera. + name: Set persons at home + description: + Set a list of persons as at home. Person's name must match a name known by + the Netatmo Indoor (Welcome) Camera. + target: + entity: + integration: netatmo + domain: camera fields: persons: description: List of names example: Bob - entity_id: - description: Entity id of the camera. - example: camera.netatmo_entrance + required: true + selector: + text: set_person_away: - description: Set a person away. If no person is set the home will be marked as empty. Person's name must match a name known by the Welcome Camera. + name: Set person away + description: + Set a person as away. If no person is set the home will be marked as empty. + Person's name must match a name known by the Netatmo Indoor (Welcome) + Camera. + target: + entity: + integration: netatmo + domain: camera fields: person: description: Person's name (optional) example: Bob - entity_id: - description: Entity id of the camera. - example: camera.netatmo_entrance + selector: + text: register_webhook: - description: Register webhook + name: Register webhook + description: Register the webhook to the Netatmo backend. unregister_webhook: - description: Unregister webhook + name: Unregister webhook + description: Unregister the webhook from the Netatmo backend. diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 60fdab5f22c..c65001b2e8f 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -39,5 +39,27 @@ "title": "Netatmo public weather sensor" } } + }, + "device_automation": { + "trigger_subtype": { + "away": "away", + "schedule": "schedule", + "hg": "frost guard" + }, + "trigger_type": { + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on", + "human": "{entity_name} detected a human", + "movement": "{entity_name} detected movement", + "person": "{entity_name} detected a person", + "person_away": "{entity_name} detected a person has left", + "animal": "{entity_name} detected an animal", + "outdoor": "{entity_name} detected an outdoor event", + "vehicle": "{entity_name} detected a vehicle", + "alarm_started": "{entity_name} detected an alarm", + "set_point": "Target temperature {entity_name} set manually", + "cancel_set_point": "{entity_name} has resumed its schedule", + "therm_mode": "{entity_name} switched to \"{subtype}\"" + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/ca.json b/homeassistant/components/netatmo/translations/ca.json index a6b8b5c2b82..809223a04ae 100644 --- a/homeassistant/components/netatmo/translations/ca.json +++ b/homeassistant/components/netatmo/translations/ca.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "a fora", + "hg": "protecci\u00f3 contra gelades", + "schedule": "programaci\u00f3" + }, + "trigger_type": { + "alarm_started": "{entity_name} ha detectat una alarma", + "animal": "{entity_name} ha detectat un animal", + "cancel_set_point": "{entity_name} ha repr\u00e8s la programaci\u00f3", + "human": "{entity_name} ha detectat un hum\u00e0", + "movement": "{entity_name} ha detectat moviment", + "outdoor": "{entity_name} ha detectat un esdeveniment a fora", + "person": "{entity_name} ha detectat una persona", + "person_away": "{entity_name} ha detectat una marxant", + "set_point": "Temperatura objectiu {entity_name} configurada manualment", + "therm_mode": "{entity_name} ha canviar a \"{subtype}\"", + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha engegat", + "vehicle": "{entity_name} ha detectat un vehicle" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/en.json b/homeassistant/components/netatmo/translations/en.json index e31d801b7a0..7e230374720 100644 --- a/homeassistant/components/netatmo/translations/en.json +++ b/homeassistant/components/netatmo/translations/en.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "away", + "hg": "frost guard", + "schedule": "schedule" + }, + "trigger_type": { + "alarm_started": "{entity_name} detected an alarm", + "animal": "{entity_name} detected an animal", + "cancel_set_point": "{entity_name} has resumed its schedule", + "human": "{entity_name} detected a human", + "movement": "{entity_name} detected movement", + "outdoor": "{entity_name} detected an outdoor event", + "person": "{entity_name} detected a person", + "person_away": "{entity_name} detected a person has left", + "set_point": "Target temperature {entity_name} set manually", + "therm_mode": "{entity_name} switched to \"{subtype}\"", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on", + "vehicle": "{entity_name} detected a vehicle" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/es.json b/homeassistant/components/netatmo/translations/es.json index 556fe2626d4..b1159c1dd9d 100644 --- a/homeassistant/components/netatmo/translations/es.json +++ b/homeassistant/components/netatmo/translations/es.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "fuera", + "hg": "protector contra las heladas", + "schedule": "Horario" + }, + "trigger_type": { + "alarm_started": "{entity_name} ha detectado una alarma", + "animal": "{entity_name} ha detectado un animal", + "cancel_set_point": "{entity_name} ha reanudado su programaci\u00f3n", + "human": "{entity_name} ha detectado una persona", + "movement": "{entity_name} ha detectado movimiento", + "outdoor": "{entity_name} ha detectado un evento en el exterior", + "person": "{entity_name} ha detectado una persona", + "person_away": "{entity_name} ha detectado que una persona se ha ido", + "set_point": "Temperatura objetivo {entity_name} fijada manualmente", + "therm_mode": "{entity_name} cambi\u00f3 a \" {subtype} \"", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado", + "vehicle": "{entity_name} ha detectado un veh\u00edculo" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/et.json b/homeassistant/components/netatmo/translations/et.json index 99e062b3842..8725eb48016 100644 --- a/homeassistant/components/netatmo/translations/et.json +++ b/homeassistant/components/netatmo/translations/et.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "eemal", + "hg": "k\u00fclmumiskaitse", + "schedule": "ajastus" + }, + "trigger_type": { + "alarm_started": "{entity_name} tuvastas h\u00e4ire", + "animal": "{entity_name} tuvastas looma", + "cancel_set_point": "{entity_name} on oma ajakava j\u00e4tkanud", + "human": "{entity_name} tuvastas inimese", + "movement": "{entity_name} tuvastas liikumise", + "outdoor": "{entity_name} tuvastas v\u00e4lise s\u00fcndmuse", + "person": "{entity_name} tuvastas isiku", + "person_away": "{entity_name} tuvastas inimese eemaldumise", + "set_point": "Sihttemperatuur {entity_name} on k\u00e4sitsi m\u00e4\u00e4ratud", + "therm_mode": "{entity_name} l\u00fclitus olekusse {subtype}.", + "turned_off": "{entity_name} l\u00fclitus v\u00e4lja", + "turned_on": "{entity_name} l\u00fclitus sisse", + "vehicle": "{entity_name} tuvastas s\u00f5iduki" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/fr.json b/homeassistant/components/netatmo/translations/fr.json index fe8fc74d273..6c294d467ab 100644 --- a/homeassistant/components/netatmo/translations/fr.json +++ b/homeassistant/components/netatmo/translations/fr.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "absent", + "hg": "garde-gel", + "schedule": "horaire" + }, + "trigger_type": { + "alarm_started": "{entity_name} a d\u00e9tect\u00e9 une alarme", + "animal": "{entity_name} a d\u00e9tect\u00e9 un animal", + "cancel_set_point": "{entity_name} a repris son programme", + "human": "{entity_name} a d\u00e9tect\u00e9 une personne", + "movement": "{entity_name} a d\u00e9tect\u00e9 un mouvement", + "outdoor": "{entity_name} a d\u00e9tect\u00e9 un \u00e9v\u00e9nement ext\u00e9rieur", + "person": "{entity_name} a d\u00e9tect\u00e9 une personne", + "person_away": "{entity_name} a d\u00e9tect\u00e9 qu\u2019une personne est partie", + "set_point": "Temp\u00e9rature cible {entity_name} d\u00e9finie manuellement", + "therm_mode": "{entity_name} est pass\u00e9 \u00e0 \"{subtype}\"", + "turned_off": "{entity_name} d\u00e9sactiv\u00e9", + "turned_on": "{entity_name} activ\u00e9", + "vehicle": "{entity_name} a d\u00e9tect\u00e9 un v\u00e9hicule" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/he.json b/homeassistant/components/netatmo/translations/he.json new file mode 100644 index 00000000000..54bef84c30a --- /dev/null +++ b/homeassistant/components/netatmo/translations/he.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "trigger_type": { + "animal": "\u05d6\u05d9\u05d4\u05d4 \u05d1\u05e2\u05dc-\u05d7\u05d9\u05d9\u05dd", + "human": "\u05d6\u05d9\u05d4\u05d4 \u05d0\u05d3\u05dd", + "movement": "\u05d6\u05d9\u05d4\u05d4 \u05ea\u05e0\u05d5\u05e2\u05d4", + "turned_off": "\u05db\u05d1\u05d4", + "turned_on": "\u05e0\u05d3\u05dc\u05e7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/it.json b/homeassistant/components/netatmo/translations/it.json index 46c2d7d2721..152f7d47597 100644 --- a/homeassistant/components/netatmo/translations/it.json +++ b/homeassistant/components/netatmo/translations/it.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "Fuori casa", + "hg": "protezione antigelo", + "schedule": "programma" + }, + "trigger_type": { + "alarm_started": "{entity_name} ha rilevato un allarme", + "animal": "{entity_name} ha rilevato un animale", + "cancel_set_point": "{entity_name} ha ripreso il suo programma", + "human": "{entity_name} ha rilevato un essere umano", + "movement": "{entity_name} ha rilevato un movimento", + "outdoor": "{entity_name} ha rilevato un evento all'esterno", + "person": "{entity_name} ha rilevato una persona", + "person_away": "{entity_name} ha rilevato che una persona \u00e8 uscita", + "set_point": "{entity_name} temperatura desiderata impostata manualmente", + "therm_mode": "{entity_name} \u00e8 passato a \"{subtype}\"", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato", + "vehicle": "{entity_name} ha rilevato un veicolo" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/ko.json b/homeassistant/components/netatmo/translations/ko.json index 8165941f0d8..320df466515 100644 --- a/homeassistant/components/netatmo/translations/ko.json +++ b/homeassistant/components/netatmo/translations/ko.json @@ -3,7 +3,8 @@ "abort": { "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "create_entry": { "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/netatmo/translations/nl.json b/homeassistant/components/netatmo/translations/nl.json index eab1d9741ad..0bdc3170a5a 100644 --- a/homeassistant/components/netatmo/translations/nl.json +++ b/homeassistant/components/netatmo/translations/nl.json @@ -2,7 +2,9 @@ "config": { "abort": { "authorize_url_timeout": "Time-out genereren autorisatie-URL.", - "missing_configuration": "Het component is niet geconfigureerd. Volg de documentatie." + "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})", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "create_entry": { "default": "Succesvol geauthenticeerd met Netatmo." @@ -13,11 +15,37 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "afwezig", + "hg": "vorstbescherming", + "schedule": "schema" + }, + "trigger_type": { + "alarm_started": "{entity_name} heeft een alarm gedetecteerd", + "animal": "{entity_name} heeft een dier gedetecteerd", + "cancel_set_point": "{entity_name} heeft zijn schema hervat", + "human": "{entity_name} heeft een mens gedetecteerd", + "movement": "{entity_name} heeft beweging gedetecteerd", + "outdoor": "{entity_name} heeft een buitengebeurtenis gedetecteerd", + "person": "{entity_name} heeft een persoon gedetecteerd", + "person_away": "{entity_name} heeft gedetecteerd dat een persoon is vertrokken", + "set_point": "Doeltemperatuur {entity_name} handmatig ingesteld", + "therm_mode": "{entity_name} is overgeschakeld naar \"{subtype}\"", + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld", + "vehicle": "{entity_name} heeft een voertuig gedetecteerd" + } + }, "options": { "step": { "public_weather": { "data": { "area_name": "Naam van het gebied", + "lat_ne": "Breedtegraad Noordoostelijke hoek", + "lat_sw": "Breedtegraad Zuidwestelijke hoek", + "lon_ne": "Lengtegraad Noordoostelijke hoek", + "lon_sw": "Lengtegraad Zuidwestelijke hoek", "mode": "Berekening", "show_on_map": "Toon op kaart" } diff --git a/homeassistant/components/netatmo/translations/no.json b/homeassistant/components/netatmo/translations/no.json index 387dbe7b26c..9e3e24d5771 100644 --- a/homeassistant/components/netatmo/translations/no.json +++ b/homeassistant/components/netatmo/translations/no.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "borte", + "hg": "frostvakt", + "schedule": "Tidsplan" + }, + "trigger_type": { + "alarm_started": "{entity_name} oppdaget en alarm", + "animal": "{entity_name} oppdaget et dyr", + "cancel_set_point": "{entity_name} har gjenopptatt tidsplanen", + "human": "{entity_name} oppdaget et menneske", + "movement": "{entity_name} oppdaget bevegelse", + "outdoor": "{entity_name} oppdaget en utend\u00f8rs hendelse", + "person": "{entity_name} oppdaget en person", + "person_away": "{entity_name} oppdaget at en person har forlatt", + "set_point": "M\u00e5ltemperatur {entity_name} angis manuelt", + "therm_mode": "{entity_name} byttet til \"{subtype}\"", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5", + "vehicle": "{entity_name} oppdaget et kj\u00f8ret\u00f8y" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/ru.json b/homeassistant/components/netatmo/translations/ru.json index c9be7e60825..b25e0843967 100644 --- a/homeassistant/components/netatmo/translations/ru.json +++ b/homeassistant/components/netatmo/translations/ru.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "\u043d\u0435 \u0434\u043e\u043c\u0430", + "hg": "\u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "schedule": "\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435" + }, + "trigger_type": { + "alarm_started": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u0440\u0435\u0432\u043e\u0433\u0443", + "animal": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0436\u0438\u0432\u043e\u0442\u043d\u043e\u0435", + "cancel_set_point": "{entity_name} \u0432\u043e\u0437\u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0435\u0442 \u0441\u0432\u043e\u0435 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "human": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0447\u0435\u043b\u043e\u0432\u0435\u043a\u0430", + "movement": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "outdoor": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u043d\u0430 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0432\u043e\u0437\u0434\u0443\u0445\u0435", + "person": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0435\u0440\u0441\u043e\u043d\u0443", + "person_away": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442, \u0447\u0442\u043e \u043f\u0435\u0440\u0441\u043e\u043d\u0430 \u0443\u0448\u043b\u0430", + "set_point": "\u0426\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 {entity_name} \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0430 \u0432\u0440\u0443\u0447\u043d\u0443\u044e", + "therm_mode": "{entity_name} \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f \u043d\u0430 \"{subtype}\"", + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "vehicle": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0435 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u043e" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/tr.json b/homeassistant/components/netatmo/translations/tr.json index 94dd5b3fb0f..69646be2292 100644 --- a/homeassistant/components/netatmo/translations/tr.json +++ b/homeassistant/components/netatmo/translations/tr.json @@ -4,6 +4,22 @@ "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." } }, + "device_automation": { + "trigger_subtype": { + "away": "uzakta", + "hg": "donma korumas\u0131", + "schedule": "Zamanlama" + }, + "trigger_type": { + "alarm_started": "{entity_name} bir alarm alg\u0131lad\u0131", + "animal": "{entity_name} bir hayvan tespit etti", + "cancel_set_point": "{entity_name} zamanlamas\u0131na devam etti", + "human": "{entity_name} bir insan alg\u0131lad\u0131", + "movement": "{entity_name} hareket alg\u0131lad\u0131", + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/translations/zh-Hant.json b/homeassistant/components/netatmo/translations/zh-Hant.json index e396deabb68..e62836f9a7e 100644 --- a/homeassistant/components/netatmo/translations/zh-Hant.json +++ b/homeassistant/components/netatmo/translations/zh-Hant.json @@ -15,6 +15,28 @@ } } }, + "device_automation": { + "trigger_subtype": { + "away": "\u96e2\u5bb6", + "hg": "\u9632\u51cd\u6a21\u5f0f", + "schedule": "\u6392\u7a0b" + }, + "trigger_type": { + "alarm_started": "{entity_name}\u5075\u6e2c\u5230\u8b66\u5831", + "animal": "{entity_name}\u5075\u6e2c\u5230\u52d5\u7269", + "cancel_set_point": "{entity_name}\u5df2\u6062\u5fa9\u5176\u6392\u7a0b", + "human": "{entity_name}\u5075\u6e2c\u5230\u4eba\u985e", + "movement": "{entity_name}\u5075\u6e2c\u5230\u52d5\u4f5c", + "outdoor": "{entity_name}\u5075\u6e2c\u5230\u6236\u5916\u52d5\u4f5c", + "person": "{entity_name}\u5075\u6e2c\u5230\u4eba\u54e1", + "person_away": "{entity_name}\u5075\u6e2c\u5230\u4eba\u54e1\u5df2\u96e2\u958b", + "set_point": "\u624b\u52d5\u8a2d\u5b9a{entity_name}\u76ee\u6a19\u6eab\u5ea6", + "therm_mode": "{entity_name}\u5207\u63db\u81f3 \"{subtype}\"", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f", + "vehicle": "{entity_name}\u5075\u6e2c\u5230\u8eca\u8f1b" + } + }, "options": { "step": { "public_weather": { diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 582fce8985c..1fe7302038e 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,19 +1,20 @@ """The Netatmo integration.""" import logging -from homeassistant.core import callback +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_EVENT_TYPE, ATTR_FACE_URL, - ATTR_ID, ATTR_IS_KNOWN, ATTR_NAME, ATTR_PERSONS, + DATA_DEVICE_IDS, DATA_PERSONS, DEFAULT_PERSON, DOMAIN, + EVENT_ID_MAP, NETATMO_EVENT, ) @@ -38,17 +39,16 @@ async def handle_webhook(hass, webhook_id, request): event_type = data.get(ATTR_EVENT_TYPE) if event_type in EVENT_TYPE_MAP: - async_send_event(hass, event_type, data) + await async_send_event(hass, event_type, data) for event_data in data.get(EVENT_TYPE_MAP[event_type], []): - async_evaluate_event(hass, event_data) + await async_evaluate_event(hass, event_data) else: - async_evaluate_event(hass, data) + await async_evaluate_event(hass, data) -@callback -def async_evaluate_event(hass, event_data): +async def async_evaluate_event(hass, event_data): """Evaluate events from webhook.""" event_type = event_data.get(ATTR_EVENT_TYPE) @@ -62,21 +62,31 @@ def async_evaluate_event(hass, event_data): person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) - async_send_event(hass, event_type, person_event_data) + await async_send_event(hass, event_type, person_event_data) else: - _LOGGER.debug("%s: %s", event_type, event_data) - async_send_event(hass, event_type, event_data) + await async_send_event(hass, event_type, event_data) -@callback -def async_send_event(hass, event_type, data): +async def async_send_event(hass, event_type, data): """Send events.""" - hass.bus.async_fire( - event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} - ) + _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( hass, f"signal-{DOMAIN}-webhook-{event_type}", {"type": event_type, "data": data}, ) + + if event_type not in EVENT_ID_MAP: + return + + data_device_id = data[EVENT_ID_MAP[event_type]] + + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={ + "type": event_type, + "data": data, + ATTR_DEVICE_ID: hass.data[DOMAIN][DATA_DEVICE_IDS].get(data_device_id), + }, + ) diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py index 7820ebb6216..62f6e8275c4 100644 --- a/homeassistant/components/nexia/entity.py +++ b/homeassistant/components/nexia/entity.py @@ -83,10 +83,12 @@ class NexiaThermostatZoneEntity(NexiaThermostatEntity): def device_info(self): """Return the device_info of the device.""" data = super().device_info + zone_name = self._zone.get_name() data.update( { "identifiers": {(DOMAIN, self._zone.zone_id)}, - "name": self._zone.get_name(), + "name": zone_name, + "suggested_area": zone_name, "via_device": (DOMAIN, self._zone.thermostat.thermostat_id), } ) diff --git a/homeassistant/components/nexia/translations/ko.json b/homeassistant/components/nexia/translations/ko.json index a918de60fe6..170411f5f73 100644 --- a/homeassistant/components/nexia/translations/ko.json +++ b/homeassistant/components/nexia/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "nexia home \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/nexia/translations/ru.json b/homeassistant/components/nexia/translations/ru.json index a19d16e3a7e..f1c7b5b8ced 100644 --- a/homeassistant/components/nexia/translations/ru.json +++ b/homeassistant/components/nexia/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/nexia/translations/sv.json b/homeassistant/components/nexia/translations/sv.json index 9cfd620ac73..60044361f65 100644 --- a/homeassistant/components/nexia/translations/sv.json +++ b/homeassistant/components/nexia/translations/sv.json @@ -10,7 +10,8 @@ "data": { "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" - } + }, + "title": "Anslut till mynexia.com" } } } diff --git a/homeassistant/components/nightscout/const.py b/homeassistant/components/nightscout/const.py index 4bb96a94c29..7e47f7ff49d 100644 --- a/homeassistant/components/nightscout/const.py +++ b/homeassistant/components/nightscout/const.py @@ -3,6 +3,5 @@ DOMAIN = "nightscout" ATTR_DEVICE = "device" -ATTR_DATE = "date" ATTR_DELTA = "delta" ATTR_DIRECTION = "direction" diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index f4ff14d7b2a..efa625577d9 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -8,10 +8,11 @@ from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from .const import ATTR_DATE, ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN +from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/nightscout/translations/et.json b/homeassistant/components/nightscout/translations/et.json index 1e77907f2af..0d00cebb6a5 100644 --- a/homeassistant/components/nightscout/translations/et.json +++ b/homeassistant/components/nightscout/translations/et.json @@ -15,7 +15,7 @@ "api_key": "API v\u00f5ti", "url": "" }, - "description": "- URL: NightScout eksemplari aadress. St: https://myhomeassistant.duckdns.org:5423\n - API v\u00f5ti (valikuline): kasutage ainult siis kui teie eksemplar on kaitstud (auth_default_roles! = readable).", + "description": "- URL: NightScout eksemplari aadress. St: https://myhomeassistant.duckdns.org:5423\n - API v\u00f5ti (valikuline): kasuta ainult siis kui eksemplar on kaitstud (auth_default_roles! = readable).", "title": "Sisesta oma Nightscouti serveri teave." } } diff --git a/homeassistant/components/nightscout/translations/ko.json b/homeassistant/components/nightscout/translations/ko.json index 0235c446e75..0408a1f61ab 100644 --- a/homeassistant/components/nightscout/translations/ko.json +++ b/homeassistant/components/nightscout/translations/ko.json @@ -4,7 +4,17 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "url": "URL \uc8fc\uc18c" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/nl.json b/homeassistant/components/nightscout/translations/nl.json index 208299fd442..0146996dce5 100644 --- a/homeassistant/components/nightscout/translations/nl.json +++ b/homeassistant/components/nightscout/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, diff --git a/homeassistant/components/nightscout/translations/ru.json b/homeassistant/components/nightscout/translations/ru.json index 738c4dfa9a3..c7688973c1b 100644 --- a/homeassistant/components/nightscout/translations/ru.json +++ b/homeassistant/components/nightscout/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "Nightscout", diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 26689e5cb0a..6417926702d 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -7,7 +7,7 @@ import sys from pycarwings2 import CarwingsError, Session import voluptuous as vol -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, HTTP_OK +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, HTTP_OK from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform @@ -34,7 +34,6 @@ DATA_RANGE_AC_OFF = "range_ac_off" CONF_INTERVAL = "update_interval" CONF_CHARGING_INTERVAL = "update_interval_charging" CONF_CLIMATE_INTERVAL = "update_interval_climate" -CONF_REGION = "region" CONF_VALID_REGIONS = ["NNA", "NE", "NCI", "NMA", "NML"] CONF_FORCE_MILES = "force_miles" @@ -272,7 +271,6 @@ class LeafDataStore: async def async_refresh_data(self, now): """Refresh the leaf data and update the datastore.""" - if self.request_in_progress: _LOGGER.debug("Refresh currently in progress for %s", self.leaf.nickname) return @@ -336,7 +334,6 @@ class LeafDataStore: async def async_get_battery(self): """Request battery update from Nissan servers.""" - try: # Request battery update from the car _LOGGER.debug("Requesting battery update, %s", self.leaf.vin) @@ -388,7 +385,6 @@ class LeafDataStore: async def async_get_climate(self): """Request climate data from Nissan servers.""" - try: return await self.hass.async_add_executor_job( self.leaf.get_latest_hvac_status diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 0a8c177b08c..608f90d5421 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -12,13 +12,12 @@ from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOSTS +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_EXCLUDE = "exclude" # Interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" CONF_OPTIONS = "scan_options" diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index d3439baf4fb..7be66dc3c59 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -2,7 +2,7 @@ import asyncio from functools import partial import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, cast import voluptuous as vol @@ -12,10 +12,12 @@ from homeassistant.core import ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.loader import bind_hass +from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify +from homeassistant.util.yaml import load_yaml # mypy: allow-untyped-defs, no-check-untyped-defs @@ -41,6 +43,9 @@ SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification" NOTIFY_SERVICES = "notify_services" +CONF_DESCRIPTION = "description" +CONF_FIELDS = "fields" + PLATFORM_SCHEMA = vol.Schema( {vol.Required(CONF_PLATFORM): cv.string, vol.Optional(CONF_NAME): cv.string}, extra=vol.ALLOW_EXTRA, @@ -161,6 +166,13 @@ class BaseNotificationService: self._target_service_name_prefix = target_service_name_prefix self.registered_targets = {} + # Load service descriptions from notify/services.yaml + integration = await async_get_integration(hass, DOMAIN) + services_yaml = integration.file_path / "services.yaml" + self.services_dict = cast( + dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + ) + async def async_register_services(self) -> None: """Create or update the notify services.""" assert self.hass @@ -185,6 +197,13 @@ class BaseNotificationService: self._async_notify_message_service, schema=NOTIFY_SERVICE_SCHEMA, ) + # Register the service description + service_desc = { + CONF_NAME: f"Send a notification via {target_name}", + CONF_DESCRIPTION: f"Sends a notification message using the {target_name} integration.", + CONF_FIELDS: self.services_dict[SERVICE_NOTIFY][CONF_FIELDS], + } + async_set_service_schema(self.hass, DOMAIN, target_name, service_desc) for stale_target_name in stale_targets: del self.registered_targets[stale_target_name] @@ -203,6 +222,14 @@ class BaseNotificationService: schema=NOTIFY_SERVICE_SCHEMA, ) + # Register the service description + service_desc = { + CONF_NAME: f"Send a notification with {self._service_name}", + CONF_DESCRIPTION: f"Sends a notification message using the {self._service_name} service.", + CONF_FIELDS: self.services_dict[SERVICE_NOTIFY][CONF_FIELDS], + } + async_set_service_schema(self.hass, DOMAIN, self._service_name, service_desc) + async def async_unregister_services(self) -> None: """Unregister the notify services.""" assert self.hass @@ -312,7 +339,7 @@ async def async_setup(hass, config): ) setup_tasks = [ - async_setup_platform(integration_name, p_config) + asyncio.create_task(async_setup_platform(integration_name, p_config)) for integration_name, p_config in config_per_platform(config, DOMAIN) ] diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 8c75c94e34a..f6918b6c09c 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -1,22 +1,37 @@ # Describes the format for available notification services notify: - description: Send a notification. + name: Send a notification + description: Sends a notification message to selected notify platforms. fields: message: + name: Message description: Message body of the notification. example: The garage door has been open for 10 minutes. + selector: + text: title: + name: Title description: Optional title for your notification. example: "Your Garage Door Friend" + selector: + text: target: - description: An array of targets to send the notification to. Optional depending on the platform. + description: + An array of targets to send the notification to. Optional depending on + the platform. example: platform specific data: - description: Extended information for notification. Optional depending on the platform. + name: Data + description: + Extended information for notification. Optional depending on the + platform. example: platform specific + selector: + object: persistent_notification: + name: Send a persistent notification description: Sends a notification to the visible in the front-end. fields: message: @@ -27,10 +42,16 @@ persistent_notification: example: "Your Garage Door Friend" apns_register: - description: Registers a device to receive push notifications. + name: Register APNS device + description: + Registers a device to receive push notifications via APNS (Apple Push + Notification Service). fields: push_id: - description: The device token, a 64 character hex string (256 bits). The device token is provided to you by your client app, which receives the token after registering itself with the remote notification service. + description: + The device token, a 64 character hex string (256 bits). The device token + is provided to you by your client app, which receives the token after + registering itself with the remote notification service. example: "72f2a8633655c5ce574fdc9b2b34ff8abdfc3b739b6ceb7a9ff06c1cbbf99f62" name: description: A friendly name for the device (optional). diff --git a/homeassistant/components/notion/translations/ko.json b/homeassistant/components/notion/translations/ko.json index 323ea126445..b5c7cadbe9b 100644 --- a/homeassistant/components/notion/translations/ko.json +++ b/homeassistant/components/notion/translations/ko.json @@ -1,9 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_devices": "\uacc4\uc815\uc5d0 \ub4f1\ub85d\ub41c \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/notion/translations/ru.json b/homeassistant/components/notion/translations/ru.json index 678eff742b5..737539424b0 100644 --- a/homeassistant/components/notion/translations/ru.json +++ b/homeassistant/components/notion/translations/ru.json @@ -4,7 +4,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e." }, "step": { diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index c8eda3690ef..12ae9d8990a 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -210,7 +210,7 @@ class NswRuralFireServiceLocationEvent(GeolocationEvent): @callback def _delete_callback(self): """Remove this entity.""" - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index e8f21fc89c2..35000dd21fa 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -291,4 +291,5 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): "name": self._thermostat.room, "model": "nVent Signature", "manufacturer": MANUFACTURER, + "suggested_area": self._thermostat.room, } diff --git a/homeassistant/components/nuheat/translations/ko.json b/homeassistant/components/nuheat/translations/ko.json index 1476e8beb0d..a533cd69093 100644 --- a/homeassistant/components/nuheat/translations/ko.json +++ b/homeassistant/components/nuheat/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc628\ub3c4 \uc870\uc808\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_thermostat": "\uc628\ub3c4 \uc870\uc808\uae30\uc758 \uc2dc\ub9ac\uc5bc \ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/nuheat/translations/ru.json b/homeassistant/components/nuheat/translations/ru.json index 09e74c0e4cb..099f6c3f1fc 100644 --- a/homeassistant/components/nuheat/translations/ru.json +++ b/homeassistant/components/nuheat/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_thermostat": "\u0421\u0435\u0440\u0438\u0439\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u0440\u043c\u043e\u0441\u0442\u0430\u0442\u0430 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", "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/nuheat/translations/sv.json b/homeassistant/components/nuheat/translations/sv.json index 9cfd620ac73..327bdf8c4ca 100644 --- a/homeassistant/components/nuheat/translations/sv.json +++ b/homeassistant/components/nuheat/translations/sv.json @@ -3,14 +3,17 @@ "error": { "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", "invalid_auth": "Ogiltig autentisering", + "invalid_thermostat": "Termostatens serienummer \u00e4r ogiltigt.", "unknown": "Ov\u00e4ntat fel" }, "step": { "user": { "data": { "password": "L\u00f6senord", + "serial_number": "Termostatens serienummer.", "username": "Anv\u00e4ndarnamn" - } + }, + "description": "F\u00e5 tillg\u00e5ng till din termostats serienummer eller ID genom att logga in p\u00e5 https://MyNuHeat.com och v\u00e4lja din termostat." } } } diff --git a/homeassistant/components/nuimo_controller/__init__.py b/homeassistant/components/nuimo_controller/__init__.py deleted file mode 100644 index 013c2caf23d..00000000000 --- a/homeassistant/components/nuimo_controller/__init__.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Support for Nuimo device over Bluetooth LE.""" -import logging -import threading -import time - -# pylint: disable=import-error -from nuimo import NuimoController, NuimoDiscoveryManager -import voluptuous as vol - -from homeassistant.const import CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "nuimo_controller" -EVENT_NUIMO = "nuimo_input" - -DEFAULT_NAME = "None" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_NUIMO = "led_matrix" -DEFAULT_INTERVAL = 2.0 - -SERVICE_NUIMO_SCHEMA = vol.Schema( - { - vol.Required("matrix"): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional("interval", default=DEFAULT_INTERVAL): float, - } -) - -DEFAULT_ADAPTER = "hci0" - - -def setup(hass, config): - """Set up the Nuimo component.""" - conf = config[DOMAIN] - mac = conf.get(CONF_MAC) - name = conf.get(CONF_NAME) - NuimoThread(hass, mac, name).start() - return True - - -class NuimoLogger: - """Handle Nuimo Controller event callbacks.""" - - def __init__(self, hass, name): - """Initialize Logger object.""" - self._hass = hass - self._name = name - - def received_gesture_event(self, event): - """Input Event received.""" - _LOGGER.debug( - "Received event: name=%s, gesture_id=%s,value=%s", - event.name, - event.gesture, - event.value, - ) - self._hass.bus.fire( - EVENT_NUIMO, {"type": event.name, "value": event.value, "name": self._name} - ) - - -class NuimoThread(threading.Thread): - """Manage one Nuimo controller.""" - - def __init__(self, hass, mac, name): - """Initialize thread object.""" - super().__init__() - self._hass = hass - self._mac = mac - self._name = name - self._hass_is_running = True - self._nuimo = None - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - - def run(self): - """Set up the connection or be idle.""" - while self._hass_is_running: - if not self._nuimo or not self._nuimo.is_connected(): - self._attach() - self._connect() - else: - time.sleep(1) - - if self._nuimo: - self._nuimo.disconnect() - self._nuimo = None - - def stop(self, event): - """Terminate Thread by unsetting flag.""" - _LOGGER.debug("Stopping thread for Nuimo %s", self._mac) - self._hass_is_running = False - - def _attach(self): - """Create a Nuimo object from MAC address or discovery.""" - - if self._nuimo: - self._nuimo.disconnect() - self._nuimo = None - - if self._mac: - self._nuimo = NuimoController(self._mac) - else: - nuimo_manager = NuimoDiscoveryManager( - bluetooth_adapter=DEFAULT_ADAPTER, delegate=DiscoveryLogger() - ) - nuimo_manager.start_discovery() - # Were any Nuimos found? - if not nuimo_manager.nuimos: - _LOGGER.debug("No Nuimo devices detected") - return - # Take the first Nuimo found. - self._nuimo = nuimo_manager.nuimos[0] - self._mac = self._nuimo.addr - - def _connect(self): - """Build up connection and set event delegator and service.""" - if not self._nuimo: - return - - try: - self._nuimo.connect() - _LOGGER.debug("Connected to %s", self._mac) - except RuntimeError as error: - _LOGGER.error("Could not connect to %s: %s", self._mac, error) - time.sleep(1) - return - - nuimo_event_delegate = NuimoLogger(self._hass, self._name) - self._nuimo.set_delegate(nuimo_event_delegate) - - def handle_write_matrix(call): - """Handle led matrix service.""" - matrix = call.data.get("matrix", None) - name = call.data.get(CONF_NAME, DEFAULT_NAME) - interval = call.data.get("interval", DEFAULT_INTERVAL) - if self._name == name and matrix: - self._nuimo.write_matrix(matrix, interval) - - self._hass.services.register( - DOMAIN, SERVICE_NUIMO, handle_write_matrix, schema=SERVICE_NUIMO_SCHEMA - ) - - self._nuimo.write_matrix(HOMEASSIST_LOGO, 2.0) - - -# must be 9x9 matrix -HOMEASSIST_LOGO = ( - " . " - + " ... " - + " ..... " - + " ....... " - + "..... ..." - + " ....... " - + " .. .... " - + " .. .... " - + "........." -) - - -class DiscoveryLogger: - """Handle Nuimo Discovery callbacks.""" - - # pylint: disable=no-self-use - def discovery_started(self): - """Discovery started.""" - _LOGGER.info("Started discovery") - - # pylint: disable=no-self-use - def discovery_finished(self): - """Discovery finished.""" - _LOGGER.info("Finished discovery") - - # pylint: disable=no-self-use - def controller_added(self, nuimo): - """Return that a controller was found.""" - _LOGGER.info("Added Nuimo: %s", nuimo) diff --git a/homeassistant/components/nuimo_controller/manifest.json b/homeassistant/components/nuimo_controller/manifest.json deleted file mode 100644 index dddd4a97523..00000000000 --- a/homeassistant/components/nuimo_controller/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "nuimo_controller", - "name": "Nuimo controller", - "documentation": "https://www.home-assistant.io/integrations/nuimo_controller", - "requirements": ["--only-binary=all nuimo==0.1.0"], - "codeowners": [] -} diff --git a/homeassistant/components/nuimo_controller/services.yaml b/homeassistant/components/nuimo_controller/services.yaml deleted file mode 100644 index d98659caa8b..00000000000 --- a/homeassistant/components/nuimo_controller/services.yaml +++ /dev/null @@ -1,17 +0,0 @@ -led_matrix: - description: Sends an LED Matrix to your display - fields: - matrix: - description: "A string representation of the matrix to be displayed. See the SDK documentation for more info: https://github.com/getSenic/nuimo-linux-python#write-to-nuimos-led-matrix" - example: "........ - 0000000. - .000000. - ..00000. - .0.0000. - .00.000. - .000000. - .000000. - ........" - interval: - description: Display interval in seconds - example: 0.5 diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index c8b19082585..4af3e0d8ed4 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,3 +1,53 @@ """The nuki component.""" -DOMAIN = "nuki" +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN + +NUKI_PLATFORMS = ["lock"] +UPDATE_INTERVAL = timedelta(seconds=30) + +NUKI_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_TOKEN): cv.string, + }, + ) +) + + +async def async_setup(hass, config): + """Set up the Nuki component.""" + hass.data.setdefault(DOMAIN, {}) + + for platform in NUKI_PLATFORMS: + confs = config.get(platform) + if confs is None: + continue + + for conf in confs: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up the Nuki entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, LOCK_DOMAIN) + ) + + return True diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py new file mode 100644 index 00000000000..f065f1c27ef --- /dev/null +++ b/homeassistant/components/nuki/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow to configure the Nuki integration.""" +import logging + +from pynuki import NukiBridge +from pynuki.bridge import InvalidCredentialsException +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.const import CONF_HOST, CONF_PORT, CONF_TOKEN + +from .const import ( # pylint: disable=unused-import + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Required(CONF_TOKEN): str, + } +) + + +async def validate_input(hass, data): + """Validate the user input allows us to connect. + + Data has the keys from USER_SCHEMA with values provided by the user. + """ + + try: + bridge = await hass.async_add_executor_job( + NukiBridge, + data[CONF_HOST], + data[CONF_TOKEN], + data[CONF_PORT], + True, + DEFAULT_TIMEOUT, + ) + + info = bridge.info() + except InvalidCredentialsException as err: + raise InvalidAuth from err + except RequestException as err: + raise CannotConnect from err + + return info + + +class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Nuki config flow.""" + + def __init__(self): + """Initialize the Nuki config flow.""" + self.discovery_schema = {} + + async def async_step_import(self, user_input=None): + """Handle a flow initiated by import.""" + return await self.async_step_validate(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + return await self.async_step_validate(user_input) + + async def async_step_dhcp(self, discovery_info: dict): + """Prepare configuration for a DHCP discovered Nuki bridge.""" + await self.async_set_unique_id(int(discovery_info.get(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_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_TOKEN): str, + } + ) + + return await self.async_step_validate() + + async def async_step_validate(self, user_input=None): + """Handle init step of a flow.""" + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(info["ids"]["hardwareId"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info["ids"]["hardwareId"], data=user_input + ) + + data_schema = self.discovery_schema or USER_SCHEMA + + 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 InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py new file mode 100644 index 00000000000..07ef49ebd88 --- /dev/null +++ b/homeassistant/components/nuki/const.py @@ -0,0 +1,6 @@ +"""Constants for Nuki.""" +DOMAIN = "nuki" + +# Defaults +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 20 diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index d0b55514a63..818784a2b2e 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -11,10 +11,9 @@ from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockEnt from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.helpers import config_validation as cv, entity_platform -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_PORT, DEFAULT_TIMEOUT -DEFAULT_PORT = 8080 -DEFAULT_TIMEOUT = 20 +_LOGGER = logging.getLogger(__name__) ATTR_BATTERY_CRITICAL = "battery_critical" ATTR_NUKI_ID = "nuki_id" @@ -38,6 +37,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Nuki lock platform.""" + _LOGGER.warning( + "Loading Nuki by lock platform configuration is deprecated and will be removed in the future" + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Nuki lock platform.""" + config = config_entry.data def get_entities(): bridge = NukiBridge( diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 09cf112d41c..7fb9a134c4c 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -1,7 +1,9 @@ { - "domain": "nuki", - "name": "Nuki", - "documentation": "https://www.home-assistant.io/integrations/nuki", - "requirements": ["pynuki==1.3.8"], - "codeowners": ["@pschmitt", "@pvizeli"] -} + "domain": "nuki", + "name": "Nuki", + "documentation": "https://www.home-assistant.io/integrations/nuki", + "requirements": ["pynuki==1.3.8"], + "codeowners": ["@pschmitt", "@pvizeli", "@pree"], + "config_flow": true, + "dhcp": [{ "hostname": "nuki_bridge_*" }] +} \ No newline at end of file diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json new file mode 100644 index 00000000000..9e1e4f5e5ab --- /dev/null +++ b/homeassistant/components/nuki/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/de.json b/homeassistant/components/nuki/translations/de.json new file mode 100644 index 00000000000..30d7e6865cd --- /dev/null +++ b/homeassistant/components/nuki/translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port", + "token": "Zugangstoken" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/es.json b/homeassistant/components/nuki/translations/es.json new file mode 100644 index 00000000000..8def4e2780d --- /dev/null +++ b/homeassistant/components/nuki/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Puerto", + "token": "Token de acceso" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/fr.json b/homeassistant/components/nuki/translations/fr.json new file mode 100644 index 00000000000..035c0732576 --- /dev/null +++ b/homeassistant/components/nuki/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de la connexion ", + "invalid_auth": "Authentification invalide ", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "Hote", + "port": "Port", + "token": "jeton d'acc\u00e8s" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/ko.json b/homeassistant/components/nuki/translations/ko.json new file mode 100644 index 00000000000..68f43847d6c --- /dev/null +++ b/homeassistant/components/nuki/translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8", + "token": "\uc561\uc138\uc2a4 \ud1a0\ud070" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/nl.json b/homeassistant/components/nuki/translations/nl.json new file mode 100644 index 00000000000..4e220dbe78d --- /dev/null +++ b/homeassistant/components/nuki/translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Poort", + "token": "Toegangstoken" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/ru.json b/homeassistant/components/nuki/translations/ru.json index bad9f35c076..a7fe1c61f5b 100644 --- a/homeassistant/components/nuki/translations/ru.json +++ b/homeassistant/components/nuki/translations/ru.json @@ -2,7 +2,7 @@ "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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/nuki/translations/zh-Hant.json b/homeassistant/components/nuki/translations/zh-Hant.json index 662d7ed6ed9..4bf21552952 100644 --- a/homeassistant/components/nuki/translations/zh-Hant.json +++ b/homeassistant/components/nuki/translations/zh-Hant.json @@ -10,7 +10,7 @@ "data": { "host": "\u4e3b\u6a5f\u7aef", "port": "\u901a\u8a0a\u57e0", - "token": "\u5b58\u53d6\u5bc6\u9470" + "token": "\u5b58\u53d6\u6b0a\u6756" } } } diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index d18416f9974..a684fef7d5d 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -1,11 +1,13 @@ # Describes the format for available Number entity services set_value: + name: Set description: Set the value of a Number entity. + target: fields: - entity_id: - description: Entity ID of the Number to set the new value. - example: number.volume value: + name: Value description: The target value the entity should be set to. example: 42 + selector: + text: diff --git a/homeassistant/components/number/translations/es.json b/homeassistant/components/number/translations/es.json new file mode 100644 index 00000000000..e709346849e --- /dev/null +++ b/homeassistant/components/number/translations/es.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "action_type": { + "set_value": "Establecer valor para {entity_name}" + } + }, + "title": "N\u00famero" +} \ No newline at end of file diff --git a/homeassistant/components/number/translations/fr.json b/homeassistant/components/number/translations/fr.json new file mode 100644 index 00000000000..9f49c3fb962 --- /dev/null +++ b/homeassistant/components/number/translations/fr.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "action_type": { + "set_value": "D\u00e9finir la valeur de {entity_name}" + } + }, + "title": "Nombre" +} \ No newline at end of file diff --git a/homeassistant/components/number/translations/nl.json b/homeassistant/components/number/translations/nl.json new file mode 100644 index 00000000000..f9a1c6b60a9 --- /dev/null +++ b/homeassistant/components/number/translations/nl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "action_type": { + "set_value": "Stel waarde in voor {entity_name}" + } + }, + "title": "Nummer" +} \ No newline at end of file diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 8a868d7bb39..7407958cdc0 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -129,7 +129,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Prepare configuration for a discovered nut device.""" self.discovery_info = discovery_info await self._async_handle_discovery_without_unique_id() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_PORT: discovery_info.get(CONF_PORT, DEFAULT_PORT), CONF_HOST: discovery_info[CONF_HOST], diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index f4fbbdef932..174405e22e2 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) ) else: - _LOGGER.warning( + _LOGGER.info( "Sensor type: %s does not appear in the NUT status " "output, cannot add", sensor_type, diff --git a/homeassistant/components/nut/translations/ko.json b/homeassistant/components/nut/translations/ko.json index 81fe8d88a90..0fb8339ddfc 100644 --- a/homeassistant/components/nut/translations/ko.json +++ b/homeassistant/components/nut/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 574ad6925ac..f055bab0203 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -35,7 +35,7 @@ CONDITION_CLASSES = { "Hot", "Cold", ], - ATTR_CONDITION_SNOWY: ["Snow", "Sleet", "Blizzard"], + ATTR_CONDITION_SNOWY: ["Snow", "Sleet", "Snow/sleet", "Blizzard"], ATTR_CONDITION_SNOWY_RAINY: [ "Rain/snow", "Rain/sleet", diff --git a/homeassistant/components/nws/translations/ko.json b/homeassistant/components/nws/translations/ko.json index 552099c7193..9fbdf026558 100644 --- a/homeassistant/components/nws/translations/ko.json +++ b/homeassistant/components/nws/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { @@ -15,7 +15,7 @@ "longitude": "\uacbd\ub3c4", "station": "METAR \uc2a4\ud14c\uc774\uc158 \ucf54\ub4dc" }, - "description": "METAR \uc2a4\ud14c\uc774\uc158 \ucf54\ub4dc\ub97c \uc9c0\uc815\ud558\uc9c0 \uc54a\uc73c\uba74 \uac00\uae4c\uc6b4 \uc2a4\ud14c\uc774\uc158\uc744 \ucc3e\ub294\ub370 \uc704\ub3c4\uc640 \uacbd\ub3c4\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.", + "description": "METAR \uc2a4\ud14c\uc774\uc158 \ucf54\ub4dc\uac00 \uc9c0\uc815\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc704\ub3c4 \ubc0f \uacbd\ub3c4\uac00 \uac00\uc7a5 \uac00\uae4c\uc6b4 \uc2a4\ud14c\uc774\uc158\uc744 \ucc3e\ub294 \ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \ud604\uc7ac API Key \ub294 \uc544\ubb34 \ud0a4\ub098 \ub123\uc5b4\ub3c4 \uc0c1\uad00 \uc5c6\uc2b5\ub2c8\ub2e4\ub9cc, \uc62c\ubc14\ub978 \uc774\uba54\uc77c \uc8fc\uc18c\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uad8c\uc7a5\ud569\ub2c8\ub2e4.", "title": "\ubbf8\uad6d \uae30\uc0c1\uccad\uc5d0 \uc5f0\uacb0\ud558\uae30" } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 34c2909188f..9f4e69bdb8c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,6 +1,5 @@ """Support for NWS weather service.""" from datetime import timedelta -import logging from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -47,8 +46,6 @@ from .const import ( NWS_DATA, ) -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 OBSERVATION_VALID_TIME = timedelta(minutes=20) diff --git a/homeassistant/components/nzbget/translations/ko.json b/homeassistant/components/nzbget/translations/ko.json index dd53d52a236..58d38b66361 100644 --- a/homeassistant/components/nzbget/translations/ko.json +++ b/homeassistant/components/nzbget/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub428. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "NZBGet : {name}", "step": { @@ -13,11 +13,11 @@ "data": { "host": "\ud638\uc2a4\ud2b8", "name": "\uc774\ub984", - "password": "\uc554\ud638", + "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "ssl": "NZBGet\uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.", - "username": "\uc0ac\uc6a9\uc790\uba85", - "verify_ssl": "NZBGet\uc740 \uc801\uc808\ud55c \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4." + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "title": "NZBGet\uc5d0 \uc5f0\uacb0" } diff --git a/homeassistant/components/nzbget/translations/nl.json b/homeassistant/components/nzbget/translations/nl.json index f5f1bfd39ed..89d58d14292 100644 --- a/homeassistant/components/nzbget/translations/nl.json +++ b/homeassistant/components/nzbget/translations/nl.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "host": "Host", "name": "Naam", "password": "Wachtwoord", "port": "Poort", diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index 56a3cc06556..7c7331990ea 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -7,15 +7,13 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -CONF_ID = "id" - DEFAULT_NAME = "OhmConnect Status" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) diff --git a/homeassistant/components/omnilogic/translations/ko.json b/homeassistant/components/omnilogic/translations/ko.json index 5389207cdda..74786104624 100644 --- a/homeassistant/components/omnilogic/translations/ko.json +++ b/homeassistant/components/omnilogic/translations/ko.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "\uc774\ubbf8 \uc124\uc815\ub418\uc5b4 \uc788\uc74c. \ud558\ub098\uc758 \uc124\uc815\ub9cc \uac00\ub2a5\ud568." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { "data": { - "password": "\uc554\ud638", - "username": "\uc0ac\uc6a9\uc790\uba85" + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" } } } diff --git a/homeassistant/components/omnilogic/translations/ru.json b/homeassistant/components/omnilogic/translations/ru.json index 9040654e58c..828f0530830 100644 --- a/homeassistant/components/omnilogic/translations/ru.json +++ b/homeassistant/components/omnilogic/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 0faf099b9bf..1d5528688dd 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.auth import indieauth +from homeassistant.components.http.const import KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN @@ -132,7 +133,9 @@ class UserOnboardingView(_BaseOnboardingView): # Return authorization code for fetching tokens and connect # during onboarding. - auth_code = hass.components.auth.create_auth_code(data["client_id"], user) + auth_code = hass.components.auth.create_auth_code( + data["client_id"], credentials + ) return self.json({"auth_code": auth_code}) @@ -183,7 +186,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): async def post(self, request, data): """Handle token creation.""" hass = request.app["hass"] - user = request["hass_user"] + refresh_token_id = request[KEY_HASS_REFRESH_TOKEN_ID] async with self._lock: if self._async_is_done(): @@ -201,8 +204,16 @@ class IntegrationOnboardingView(_BaseOnboardingView): "invalid client id or redirect uri", HTTP_BAD_REQUEST ) + refresh_token = await hass.auth.async_get_refresh_token(refresh_token_id) + if refresh_token is None or refresh_token.credential is None: + return self.json_message( + "Credentials for user not available", HTTP_FORBIDDEN + ) + # Return authorization code so we can redirect user and log them in - auth_code = hass.components.auth.create_auth_code(data["client_id"], user) + auth_code = hass.components.auth.create_auth_code( + data["client_id"], refresh_token.credential + ) return self.json({"auth_code": auth_code}) diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py index c6a164e913b..74c668a3d2c 100644 --- a/homeassistant/components/ondilo_ico/config_flow.py +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -7,8 +7,6 @@ from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN from .oauth_impl import OndiloOauth2Implementation -_LOGGER = logging.getLogger(__name__) - class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN diff --git a/homeassistant/components/ondilo_ico/translations/fr.json b/homeassistant/components/ondilo_ico/translations/fr.json new file mode 100644 index 00000000000..c05fc0caaa6 --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." + }, + "create_entry": { + "default": "Authentification r\u00e9ussie" + }, + "step": { + "pick_implementation": { + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + } + } + }, + "title": "Ondilo ICO" +} \ No newline at end of file diff --git a/homeassistant/components/ondilo_ico/translations/ko.json b/homeassistant/components/ondilo_ico/translations/ko.json new file mode 100644 index 00000000000..fa000ea1c06 --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + }, + "create_entry": { + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ondilo_ico/translations/nl.json b/homeassistant/components/ondilo_ico/translations/nl.json new file mode 100644 index 00000000000..8a91dff086f --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen." + }, + "create_entry": { + "default": "Succesvol geauthenticeerd" + }, + "step": { + "pick_implementation": { + "title": "Kies een authenticatie methode" + } + } + }, + "title": "Ondilo ICO" +} \ No newline at end of file diff --git a/homeassistant/components/onewire/translations/ko.json b/homeassistant/components/onewire/translations/ko.json new file mode 100644 index 00000000000..871482b766b --- /dev/null +++ b/homeassistant/components/onewire/translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "owserver": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onewire/translations/nl.json b/homeassistant/components/onewire/translations/nl.json index ae155ccf2c2..77ac79c1597 100644 --- a/homeassistant/components/onewire/translations/nl.json +++ b/homeassistant/components/onewire/translations/nl.json @@ -4,10 +4,15 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_path": "Directory niet gevonden." }, "step": { "owserver": { + "data": { + "host": "Host", + "port": "Poort" + }, "title": "Owserver-details instellen" }, "user": { diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index ac8cfa5e4b6..7ac9b5fdfc6 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -118,15 +118,20 @@ ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" -def _parse_onkyo_tuple(tup): - """Parse a tuple returned from the eiscp library.""" - if len(tup) < 2: +def _parse_onkyo_payload(payload): + """Parse a payload returned from the eiscp library.""" + if isinstance(payload, bool): + # command not supported by the device + return False + + if len(payload) < 2: + # no value return None - if isinstance(tup[1], str): - return tup[1].split(",") + if isinstance(payload[1], str): + return payload[1].split(",") - return tup[1] + return payload[1] def _tuple_get(tup, index, default=None): @@ -267,6 +272,8 @@ class OnkyoDevice(MediaPlayerEntity): self._reverse_mapping = {value: key for key, value in sources.items()} self._attributes = {} self._hdmi_out_supported = True + self._audio_info_supported = True + self._video_info_supported = True def command(self, command): """Run an eiscp command and catch connection errors.""" @@ -309,12 +316,14 @@ class OnkyoDevice(MediaPlayerEntity): else: hdmi_out_raw = [] preset_raw = self.command("preset query") - audio_information_raw = self.command("audio-information query") - video_information_raw = self.command("video-information query") + if self._audio_info_supported: + audio_information_raw = self.command("audio-information query") + if self._video_info_supported: + video_information_raw = self.command("video-information query") if not (volume_raw and mute_raw and current_source_raw): return - sources = _parse_onkyo_tuple(current_source_raw) + sources = _parse_onkyo_payload(current_source_raw) for source in sources: if source in self._source_mapping: @@ -441,7 +450,11 @@ class OnkyoDevice(MediaPlayerEntity): self.command(f"hdmi-output-selector={output}") def _parse_audio_information(self, audio_information_raw): - values = _parse_onkyo_tuple(audio_information_raw) + values = _parse_onkyo_payload(audio_information_raw) + if values is False: + self._audio_info_supported = False + return + if values: info = { "format": _tuple_get(values, 1), @@ -456,7 +469,11 @@ class OnkyoDevice(MediaPlayerEntity): self._attributes.pop(ATTR_AUDIO_INFORMATION, None) def _parse_video_information(self, video_information_raw): - values = _parse_onkyo_tuple(video_information_raw) + values = _parse_onkyo_payload(video_information_raw) + if values is False: + self._video_info_supported = False + return + if values: info = { "input_resolution": _tuple_get(values, 1), diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 84761a4777f..c0851cbe32f 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -250,14 +250,14 @@ class ONVIFDevice: pullpoint = False try: pullpoint = await self.events.async_start() - except (ONVIFError, Fault): + except (ONVIFError, Fault, RequestError): pass ptz = False try: self.device.get_definition("ptz") ptz = True - except ONVIFError: + except (ONVIFError, Fault, RequestError): pass return Capabilities(snapshot, pullpoint, ptz) diff --git a/homeassistant/components/onvif/translations/ko.json b/homeassistant/components/onvif/translations/ko.json index 3b992e8d35f..173cdb88512 100644 --- a/homeassistant/components/onvif/translations/ko.json +++ b/homeassistant/components/onvif/translations/ko.json @@ -1,12 +1,15 @@ { "config": { "abort": { - "already_configured": "ONVIF \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "ONVIF \uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "no_h264": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c H264 \uc2a4\ud2b8\ub9bc\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uae30\uae30\uc5d0\uc11c \ud504\ub85c\ud544 \uad6c\uc131\uc744 \ud655\uc778\ud574\uc8fc\uc138\uc694.", "no_mac": "ONVIF \uae30\uae30\uc758 \uace0\uc720 ID \ub97c \uad6c\uc131\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "onvif_error": "ONVIF \uae30\uae30 \uc124\uc815 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 \ub85c\uadf8\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "auth": { "data": { diff --git a/homeassistant/components/opentherm_gw/translations/ko.json b/homeassistant/components/opentherm_gw/translations/ko.json index eece1492002..6f3ac939ad1 100644 --- a/homeassistant/components/opentherm_gw/translations/ko.json +++ b/homeassistant/components/opentherm_gw/translations/ko.json @@ -1,7 +1,8 @@ { "config": { "error": { - "already_configured": "OpenTherm Gateway \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "id_exists": "OpenTherm Gateway id \uac00 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/opentherm_gw/translations/nl.json b/homeassistant/components/opentherm_gw/translations/nl.json index 7c9c89381e8..e832e790c1e 100644 --- a/homeassistant/components/opentherm_gw/translations/nl.json +++ b/homeassistant/components/opentherm_gw/translations/nl.json @@ -2,6 +2,7 @@ "config": { "error": { "already_configured": "Gateway al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", "id_exists": "Gateway id bestaat al" }, "step": { diff --git a/homeassistant/components/openuv/translations/ko.json b/homeassistant/components/openuv/translations/ko.json index 480b745fe36..ee211d3cbd5 100644 --- a/homeassistant/components/openuv/translations/ko.json +++ b/homeassistant/components/openuv/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/openweathermap/translations/et.json b/homeassistant/components/openweathermap/translations/et.json index 26c688482fd..e548c07236e 100644 --- a/homeassistant/components/openweathermap/translations/et.json +++ b/homeassistant/components/openweathermap/translations/et.json @@ -17,7 +17,7 @@ "mode": "Re\u017eiim", "name": "Sidumise nimi" }, - "description": "Seadistage OpenWeatherMapi sidumine. API-v\u00f5tme loomiseks minge aadressile https://openweathermap.org/appid", + "description": "Seadista OpenWeatherMapi sidumine. API-v\u00f5tme loomiseks mine aadressile https://openweathermap.org/appid", "title": "OpenWeatherMap" } } diff --git a/homeassistant/components/openweathermap/translations/ko.json b/homeassistant/components/openweathermap/translations/ko.json index 9560f447250..1514ada24ec 100644 --- a/homeassistant/components/openweathermap/translations/ko.json +++ b/homeassistant/components/openweathermap/translations/ko.json @@ -1,17 +1,21 @@ { "config": { "abort": { - "already_configured": "\uc774\ub7ec\ud55c \uc88c\ud45c\uc5d0 \ub300\ud55c OpenWeatherMap \ud1b5\ud569\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { "data": { - "api_key": "OpenWeatherMap API \ud0a4", + "api_key": "API \ud0a4", "language": "\uc5b8\uc5b4", "latitude": "\uc704\ub3c4", "longitude": "\uacbd\ub3c4", "mode": "\ubaa8\ub4dc", - "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uba85" + "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc774\ub984" }, "description": "OpenWeatherMap \ud1b5\ud569\uc744 \uc124\uc815\ud558\uc138\uc694. API \ud0a4\ub97c \uc0dd\uc131\ud558\ub824\uba74 https://openweathermap.org/appid\ub85c \uc774\ub3d9\ud558\uc2ed\uc2dc\uc624.", "title": "OpenWeatherMap" diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py index f395415d89e..0b2f7aac2d0 100644 --- a/homeassistant/components/ovo_energy/config_flow.py +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -62,7 +62,6 @@ class OVOEnergyFlowHandler(ConfigFlow, domain=DOMAIN): if user_input and user_input.get(CONF_USERNAME): self.username = user_input[CONF_USERNAME] - # pylint: disable=no-member self.context["title_placeholders"] = {CONF_USERNAME: self.username} if user_input is not None and user_input.get(CONF_PASSWORD) is not None: diff --git a/homeassistant/components/ovo_energy/translations/fr.json b/homeassistant/components/ovo_energy/translations/fr.json index 351e20641aa..9be6b4d3c11 100644 --- a/homeassistant/components/ovo_energy/translations/fr.json +++ b/homeassistant/components/ovo_energy/translations/fr.json @@ -5,11 +5,14 @@ "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, + "flow_title": "OVO Energy: {username}", "step": { "reauth": { "data": { "password": "Mot de passe" - } + }, + "description": "L'authentification a \u00e9chou\u00e9 pour OVO Energy. Veuillez saisir vos informations d'identification actuelles.", + "title": "R\u00e9authentification" }, "user": { "data": { diff --git a/homeassistant/components/ovo_energy/translations/ko.json b/homeassistant/components/ovo_energy/translations/ko.json index 26372afc28e..07ef8d8e166 100644 --- a/homeassistant/components/ovo_energy/translations/ko.json +++ b/homeassistant/components/ovo_energy/translations/ko.json @@ -1,10 +1,21 @@ { "config": { "error": { - "already_configured": "\uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { + "reauth": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + } + }, "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, "title": "OVO Energy \uacc4\uc815 \ucd94\uac00\ud558\uae30" } } diff --git a/homeassistant/components/ovo_energy/translations/nl.json b/homeassistant/components/ovo_energy/translations/nl.json index daa12f9e569..7a2b5b757bb 100644 --- a/homeassistant/components/ovo_energy/translations/nl.json +++ b/homeassistant/components/ovo_energy/translations/nl.json @@ -2,10 +2,16 @@ "config": { "error": { "already_configured": "Account is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie" }, + "flow_title": "OVO Energy: {username}", "step": { "reauth": { + "data": { + "password": "Wachtwoord" + }, + "description": "Authenticatie mislukt voor OVO Energy. Voer uw huidige inloggegevens in.", "title": "Opnieuw verifi\u00ebren" }, "user": { diff --git a/homeassistant/components/ovo_energy/translations/ru.json b/homeassistant/components/ovo_energy/translations/ru.json index dd422bac01f..47a94f6a24a 100644 --- a/homeassistant/components/ovo_energy/translations/ru.json +++ b/homeassistant/components/ovo_energy/translations/ru.json @@ -3,7 +3,7 @@ "error": { "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.", "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "OVO Energy: {username}", "step": { diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 3a4aac6bfd1..bd01284329b 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -304,7 +304,7 @@ async def async_handle_waypoint(hass, name_base, waypoint): if hass.states.get(entity_id) is not None: return - zone = zone_comp.Zone( + zone = zone_comp.Zone.from_yaml( { zone_comp.CONF_NAME: pretty_name, zone_comp.CONF_LATITUDE: lat, @@ -313,7 +313,6 @@ async def async_handle_waypoint(hass, name_base, waypoint): zone_comp.CONF_ICON: zone_comp.ICON_IMPORT, zone_comp.CONF_PASSIVE: False, }, - False, ) zone.hass = hass zone.entity_id = entity_id diff --git a/homeassistant/components/owntracks/translations/et.json b/homeassistant/components/owntracks/translations/et.json index 16ef569d9a4..2ee171365a4 100644 --- a/homeassistant/components/owntracks/translations/et.json +++ b/homeassistant/components/owntracks/translations/et.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, "create_entry": { - "default": "\n\nAva Android seadmes [rakendus OwnTracks] ( {android_url} ), mine eelistustele - > \u00fchendus. Muuda j\u00e4rgmisi seadeid:\n - Re\u017eiim: privaatne HTTP\n - Host: {webhook_url}\n - Identifitseerimine:\n - kasutajanimi: \" \"\n - seadme ID: \" \"\n\n IOS-is ava rakendus [OwnTracks] ( {ios_url} ), puuduta vasakus \u00fclanurgas ikooni (i) - > seaded. Muuda j\u00e4rgmisi seadeid:\n - Re\u017eiim: HTTP\n - URL: {webhook_url}\n - L\u00fclitage autentimine sisse\n - UserID: \" \" \"\n\n {secret}\n\n Lisateavet leiad [dokumentatsioonist] ( {docs_url} )." + "default": "\n\nAva Android seadmes [rakendus OwnTracks] ( {android_url} ), mine eelistustele - > \u00fchendus. Muuda j\u00e4rgmisi seadeid:\n - Re\u017eiim: privaatne HTTP\n - Host: {webhook_url}\n - Identifitseerimine:\n - kasutajanimi: \" \"\n - seadme ID: \" \"\n\n IOS-is ava rakendus [OwnTracks] ( {ios_url} ), puuduta vasakus \u00fclanurgas ikooni (i) - > seaded. Muuda j\u00e4rgmisi seadeid:\n - Re\u017eiim: HTTP\n - URL: {webhook_url}\n - L\u00fclita autentimine sisse\n - UserID: \" \" \"\n\n {secret}\n\n Lisateavet leiad [dokumentatsioonist] ( {docs_url} )." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/ko.json b/homeassistant/components/owntracks/translations/ko.json index 3cde37528c2..6e558e54627 100644 --- a/homeassistant/components/owntracks/translations/ko.json +++ b/homeassistant/components/owntracks/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "\n\nAndroid \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({android_url}) \uc744 \uc5f4\uace0 preferences -> connection \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\niOS \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({ios_url}) \uc744 \uc5f4\uace0 \uc67c\ucabd \uc0c1\ub2e8\uc758 (i) \uc544\uc774\ucf58\uc744 \ud0ed\ud558\uc5ec \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret} \n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/ozw/entity.py b/homeassistant/components/ozw/entity.py index 9c494a514e0..c1cb9617a5c 100644 --- a/homeassistant/components/ozw/entity.py +++ b/homeassistant/components/ozw/entity.py @@ -268,7 +268,7 @@ class ZWaveDeviceEntity(Entity): if not self.values: return # race condition: delete already requested if values_id == self.values.values_id: - await self.async_remove() + await self.async_remove(force_remove=True) def create_device_name(node: OZWNode): diff --git a/homeassistant/components/ozw/fan.py b/homeassistant/components/ozw/fan.py index 818bd710496..505959dd343 100644 --- a/homeassistant/components/ozw/fan.py +++ b/homeassistant/components/ozw/fan.py @@ -1,30 +1,24 @@ """Support for Z-Wave fans.""" -import logging import math from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_SET_SPEED, FanEntity, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .const import DATA_UNSUBSCRIBE, DOMAIN from .entity import ZWaveDeviceEntity -_LOGGER = logging.getLogger(__name__) - SUPPORTED_FEATURES = SUPPORT_SET_SPEED - -# Value will first be divided to an integer -VALUE_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} -SPEED_TO_VALUE = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 50, SPEED_HIGH: 99} -SPEED_LIST = [*SPEED_TO_VALUE] +SPEED_RANGE = (1, 99) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -44,26 +38,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class ZwaveFan(ZWaveDeviceEntity, FanEntity): """Representation of a Z-Wave fan.""" - def __init__(self, values): - """Initialize the fan.""" - super().__init__(values) - self._previous_speed = None - - async def async_set_speed(self, speed): - """Set the speed of the fan.""" - if speed not in SPEED_TO_VALUE: - _LOGGER.warning("Invalid speed received: %s", speed) - return - self._previous_speed = speed - self.values.primary.send_value(SPEED_TO_VALUE[speed]) - - async def async_turn_on(self, speed=None, **kwargs): - """Turn the device on.""" - if speed is None: + async def async_set_percentage(self, percentage): + """Set the speed percentage of the fan.""" + if percentage is None: # Value 255 tells device to return to previous value - self.values.primary.send_value(255) + zwave_speed = 255 + elif percentage == 0: + zwave_speed = 0 else: - await self.async_set_speed(speed) + zwave_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + self.values.primary.send_value(zwave_speed) + + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): + """Turn the device on.""" + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs): """Turn the device off.""" @@ -75,19 +65,18 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): return self.values.primary.value > 0 @property - def speed(self): + def percentage(self): """Return the current speed. The Z-Wave speed value is a byte 0-255. 255 means previous value. The normal range of the speed is 0-99. 0 means off. """ - value = math.ceil(self.values.primary.value * 3 / 100) - return VALUE_TO_SPEED.get(value, self._previous_speed) + return ranged_value_to_percentage(SPEED_RANGE, self.values.primary.value) @property - def speed_list(self): - """Get the list of available speeds.""" - return SPEED_LIST + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) @property def supported_features(self): diff --git a/homeassistant/components/ozw/translations/fr.json b/homeassistant/components/ozw/translations/fr.json index c4ea835d86c..bf4ba5c6995 100644 --- a/homeassistant/components/ozw/translations/fr.json +++ b/homeassistant/components/ozw/translations/fr.json @@ -5,6 +5,7 @@ "addon_install_failed": "\u00c9chec de l\u2019installation de l'add-on OpenZWave.", "addon_set_config_failed": "\u00c9chec de la configuration OpenZWave.", "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "mqtt_required": "L'int\u00e9gration MQTT n'est pas configur\u00e9e", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, @@ -18,6 +19,9 @@ "hassio_confirm": { "title": "Configurer l\u2019int\u00e9gration OpenZWave avec l\u2019add-on OpenZWave" }, + "install_addon": { + "title": "L'installation du module compl\u00e9mentaire OpenZWave a commenc\u00e9" + }, "on_supervisor": { "data": { "use_addon": "Utiliser l'add-on OpenZWave Supervisor" diff --git a/homeassistant/components/ozw/translations/ko.json b/homeassistant/components/ozw/translations/ko.json index 98b965d5dd2..ba37dccdd68 100644 --- a/homeassistant/components/ozw/translations/ko.json +++ b/homeassistant/components/ozw/translations/ko.json @@ -1,7 +1,17 @@ { "config": { "abort": { - "mqtt_required": "MQTT \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \uc124\uc815\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "mqtt_required": "MQTT \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \uc124\uc815\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "step": { + "start_addon": { + "data": { + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/ozw/translations/nl.json b/homeassistant/components/ozw/translations/nl.json index 4497654e7f3..80ef72a061e 100644 --- a/homeassistant/components/ozw/translations/nl.json +++ b/homeassistant/components/ozw/translations/nl.json @@ -1,8 +1,23 @@ { "config": { "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", "mqtt_required": "De [%%] integratie is niet ingesteld", "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + }, + "step": { + "hassio_confirm": { + "title": "OpenZWave integratie instellen met de OpenZWave add-on" + }, + "install_addon": { + "title": "De OpenZWave add-on installatie is gestart" + }, + "start_addon": { + "data": { + "usb_path": "USB-apparaatpad" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/ko.json b/homeassistant/components/panasonic_viera/translations/ko.json index fc2fd7827ab..0f3252a4ab1 100644 --- a/homeassistant/components/panasonic_viera/translations/ko.json +++ b/homeassistant/components/panasonic_viera/translations/ko.json @@ -1,18 +1,20 @@ { "config": { "abort": { - "already_configured": "\uc774 Panasonic Viera TV \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \uc790\uc138\ud55c \uc815\ubcf4\ub294 \ub85c\uadf8\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_pin_code": "\uc785\ub825\ud55c PIN \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "pairing": { "data": { - "pin": "PIN" + "pin": "PIN \ucf54\ub4dc" }, - "description": "TV \uc5d0 \ud45c\uc2dc\ub41c PIN \uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", + "description": "TV \uc5d0 \ud45c\uc2dc\ub41c PIN \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", "title": "\ud398\uc5b4\ub9c1\ud558\uae30" }, "user": { diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 5f08f79dc00..589cc97baea 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.template import Template from homeassistant.loader import bind_hass from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -35,8 +36,8 @@ SERVICE_MARK_READ = "mark_read" SCHEMA_SERVICE_CREATE = vol.Schema( { - vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE): cv.template, + vol.Required(ATTR_MESSAGE): vol.Any(cv.dynamic_template, cv.string), + vol.Optional(ATTR_TITLE): vol.Any(cv.dynamic_template, cv.string), vol.Optional(ATTR_NOTIFICATION_ID): cv.string, } ) @@ -118,22 +119,24 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: attr = {} if title is not None: - try: - title.hass = hass - title = title.async_render(parse_result=False) - except TemplateError as ex: - _LOGGER.error("Error rendering title %s: %s", title, ex) - title = title.template + if isinstance(title, Template): + try: + title.hass = hass + title = title.async_render(parse_result=False) + except TemplateError as ex: + _LOGGER.error("Error rendering title %s: %s", title, ex) + title = title.template attr[ATTR_TITLE] = title attr[ATTR_FRIENDLY_NAME] = title - try: - message.hass = hass - message = message.async_render(parse_result=False) - except TemplateError as ex: - _LOGGER.error("Error rendering message %s: %s", message, ex) - message = message.template + if isinstance(message, Template): + try: + message.hass = hass + message = message.async_render(parse_result=False) + except TemplateError as ex: + _LOGGER.error("Error rendering message %s: %s", message, ex) + message = message.template attr[ATTR_MESSAGE] = message diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index d0c0e9eccc8..d3e17d904ea 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -306,14 +306,12 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): yaml_collection, ) - collection.attach_entity_component_collection( - entity_component, yaml_collection, lambda conf: Person(conf, False) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, entity_component, yaml_collection, Person ) - collection.attach_entity_component_collection( - entity_component, storage_collection, lambda conf: Person(conf, True) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, entity_component, storage_collection, Person.from_yaml ) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) await yaml_collection.async_load( await filter_yaml_data(hass, config.get(DOMAIN, [])) @@ -358,10 +356,10 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): class Person(RestoreEntity): """Represent a tracked person.""" - def __init__(self, config, editable): + def __init__(self, config): """Set up person.""" self._config = config - self._editable = editable + self.editable = True self._latitude = None self._longitude = None self._gps_accuracy = None @@ -369,6 +367,13 @@ class Person(RestoreEntity): self._state = None self._unsub_track_device = None + @classmethod + def from_yaml(cls, config): + """Return entity instance initialized from yaml storage.""" + person = cls(config) + person.editable = False + return person + @property def name(self): """Return the name of the entity.""" @@ -395,7 +400,7 @@ class Person(RestoreEntity): @property def state_attributes(self): """Return the state attributes of the person.""" - data = {ATTR_EDITABLE: self._editable, ATTR_ID: self.unique_id} + data = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id} if self._latitude is not None: data[ATTR_LATITUDE] = self._latitude if self._longitude is not None: diff --git a/homeassistant/components/person/significant_change.py b/homeassistant/components/person/significant_change.py new file mode 100644 index 00000000000..d9c1ec6cc23 --- /dev/null +++ b/homeassistant/components/person/significant_change.py @@ -0,0 +1,21 @@ +"""Helper to test significant Person state changes.""" +from typing import Any, Optional + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> Optional[bool]: + """Test if state significantly changed.""" + + if new_state != old_state: + return True + + return False diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 4b011c9f207..f3c2eb59789 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -1 +1,174 @@ -"""The philips_js component.""" +"""The Philips TV integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Callable, Dict, Optional + +from haphilipsjs import ConnectionFailure, PhilipsTV + +from homeassistant.components.automation import AutomationActionType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_VERSION, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant, callback +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS = ["media_player"] + +LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Philips TV component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Philips TV from a config entry.""" + + tvapi = PhilipsTV( + entry.data[CONF_HOST], + entry.data[CONF_API_VERSION], + username=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + ) + + coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi) + + await coordinator.async_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class PluggableAction: + """A pluggable action handler.""" + + def __init__(self, update: Callable[[], None]): + """Initialize.""" + self._update = update + self._actions: Dict[Any, AutomationActionType] = {} + + def __bool__(self): + """Return if we have something attached.""" + return bool(self._actions) + + @callback + def async_attach(self, action: AutomationActionType, variables: Dict[str, Any]): + """Attach a device trigger for turn on.""" + + @callback + def _remove(): + del self._actions[_remove] + self._update() + + job = HassJob(action) + + self._actions[_remove] = (job, variables) + self._update() + + return _remove + + async def async_run( + self, hass: HomeAssistantType, context: Optional[Context] = None + ): + """Run all turn on triggers.""" + for job, variables in self._actions.values(): + hass.async_run_hass_job(job, variables, context) + + +class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator to update data.""" + + def __init__(self, hass, api: PhilipsTV) -> None: + """Set up the coordinator.""" + self.api = api + self._notify_future: Optional[asyncio.Task] = None + + @callback + def _update_listeners(): + for update_callback in self._listeners: + update_callback() + + self.turn_on = PluggableAction(_update_listeners) + + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + request_refresh_debouncer=Debouncer( + hass, LOGGER, cooldown=2.0, immediate=False + ), + ) + + async def _notify_task(self): + while self.api.on and self.api.notify_change_supported: + if await self.api.notifyChange(130): + self.async_set_updated_data(None) + + @callback + def _async_notify_stop(self): + if self._notify_future: + self._notify_future.cancel() + self._notify_future = None + + @callback + def _async_notify_schedule(self): + if ( + (self._notify_future is None or self._notify_future.done()) + and self.api.on + and self.api.notify_change_supported + ): + self._notify_future = self.hass.loop.create_task(self._notify_task()) + + @callback + def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: + """Remove data update.""" + super().async_remove_listener(update_callback) + if not self._listeners: + self._async_notify_stop() + + @callback + def _async_stop_refresh(self, event: asyncio.Event) -> None: + super()._async_stop_refresh(event) + self._async_notify_stop() + + @callback + async def _async_update_data(self): + """Fetch the latest data from the source.""" + try: + await self.api.update() + self._async_notify_schedule() + except ConnectionFailure: + pass diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py new file mode 100644 index 00000000000..778bcba282b --- /dev/null +++ b/homeassistant/components/philips_js/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Philips TV integration.""" +import platform +from typing import Any, Dict, Optional, Tuple, TypedDict + +from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import ( + CONF_API_VERSION, + CONF_HOST, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) + +from . import LOGGER +from .const import ( # pylint:disable=unused-import + CONF_SYSTEM, + CONST_APP_ID, + CONST_APP_NAME, + DOMAIN, +) + + +class FlowUserDict(TypedDict): + """Data for user step.""" + + host: str + + +async def validate_input( + hass: core.HomeAssistant, host: str, api_version: int +) -> Tuple[Dict, PhilipsTV]: + """Validate the user input allows us to connect.""" + hub = PhilipsTV(host, api_version) + + await hub.getSystem() + await hub.setTransport(hub.secured_transport) + + if not hub.system: + raise ConnectionFailure("System data is empty") + + return hub + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Philips TV.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _current = {} + _hub: PhilipsTV + _pair_state: Any + + async def async_step_import(self, conf: Dict[str, Any]): + """Import a configuration from config.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == conf[CONF_HOST]: + return self.async_abort(reason="already_configured") + + return await self.async_step_user( + { + CONF_HOST: conf[CONF_HOST], + CONF_API_VERSION: conf[CONF_API_VERSION], + } + ) + + async def _async_create_current(self): + + system = self._current[CONF_SYSTEM] + return self.async_create_entry( + title=f"{system['name']} ({system['serialnumber']})", + data=self._current, + ) + + async def async_step_pair(self, user_input: Optional[Dict] = None): + """Attempt to pair with device.""" + assert self._hub + + errors = {} + schema = vol.Schema( + { + vol.Required(CONF_PIN): str, + } + ) + + if not user_input: + try: + self._pair_state = await self._hub.pairRequest( + CONST_APP_ID, + CONST_APP_NAME, + platform.node(), + platform.system(), + "native", + ) + except PairingFailure as exc: + LOGGER.debug(str(exc)) + return self.async_abort( + reason="pairing_failure", + description_placeholders={"error_id": exc.data.get("error_id")}, + ) + return self.async_show_form( + step_id="pair", data_schema=schema, errors=errors + ) + + try: + username, password = await self._hub.pairGrant( + self._pair_state, user_input[CONF_PIN] + ) + except PairingFailure as exc: + LOGGER.debug(str(exc)) + if exc.data.get("error_id") == "INVALID_PIN": + errors[CONF_PIN] = "invalid_pin" + return self.async_show_form( + step_id="pair", data_schema=schema, errors=errors + ) + + return self.async_abort( + reason="pairing_failure", + description_placeholders={"error_id": exc.data.get("error_id")}, + ) + + self._current[CONF_USERNAME] = username + self._current[CONF_PASSWORD] = password + return await self._async_create_current() + + async def async_step_user(self, user_input: Optional[FlowUserDict] = None): + """Handle the initial step.""" + errors = {} + if user_input: + self._current = user_input + try: + hub = await validate_input( + self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION] + ) + except ConnectionFailure as exc: + LOGGER.error(str(exc)) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + + await self.async_set_unique_id(hub.system["serialnumber"]) + self._abort_if_unique_id_configured() + + self._current[CONF_SYSTEM] = hub.system + self._current[CONF_API_VERSION] = hub.api_version + self._hub = hub + + if hub.pairing_type == "digest_auth_pairing": + return await self.async_step_pair() + return await self._async_create_current() + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str, + vol.Required( + CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1) + ): vol.In([1, 5, 6]), + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/philips_js/const.py b/homeassistant/components/philips_js/const.py new file mode 100644 index 00000000000..5769a8979ce --- /dev/null +++ b/homeassistant/components/philips_js/const.py @@ -0,0 +1,7 @@ +"""The Philips TV constants.""" + +DOMAIN = "philips_js" +CONF_SYSTEM = "system" + +CONST_APP_ID = "homeassistant.io" +CONST_APP_NAME = "Home Assistant" diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py new file mode 100644 index 00000000000..2a60a1664bc --- /dev/null +++ b/homeassistant/components/philips_js/device_trigger.py @@ -0,0 +1,67 @@ +"""Provides device automations for control of device.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry +from homeassistant.helpers.typing import ConfigType + +from . import PhilipsTVDataUpdateCoordinator +from .const import DOMAIN + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_TYPES = {TRIGGER_TYPE_TURN_ON} +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for device.""" + triggers = [] + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: TRIGGER_TYPE_TURN_ON, + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> Optional[CALLBACK_TYPE]: + """Attach a trigger.""" + registry: DeviceRegistry = await async_get_registry(hass) + if config[CONF_TYPE] == TRIGGER_TYPE_TURN_ON: + variables = { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": config[CONF_DEVICE_ID], + "description": f"philips_js '{config[CONF_TYPE]}' event", + } + } + + device = registry.async_get(config[CONF_DEVICE_ID]) + for config_entry_id in device.config_entries: + coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN].get( + config_entry_id + ) + if coordinator: + return coordinator.turn_on.async_attach(action, variables) + + return None diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 74473827424..e1e1fa69b6b 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,6 +2,11 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==0.0.8"], - "codeowners": ["@elupus"] -} + "requirements": [ + "ha-philipsjs==2.3.0" + ], + "codeowners": [ + "@elupus" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 7ccec14406a..2b2714b20ce 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -1,25 +1,32 @@ """Media Player component to integrate TVs exposing the Joint Space API.""" -from datetime import timedelta -import logging +from typing import Any, Dict, Optional -from haphilipsjs import PhilipsTV +from haphilipsjs import ConnectionFailure import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.media_player import ( + DEVICE_CLASS_TV, PLATFORM_SCHEMA, BrowseMedia, MediaPlayerEntity, ) from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, MEDIA_CLASS_CHANNEL, MEDIA_CLASS_DIRECTORY, + MEDIA_TYPE_APP, + MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNELS, SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, @@ -34,11 +41,13 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import call_later, track_time_interval -from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) +from . import LOGGER as _LOGGER, PhilipsTVDataUpdateCoordinator +from .const import CONF_SYSTEM, DOMAIN SUPPORT_PHILIPS_JS = ( SUPPORT_TURN_OFF @@ -50,28 +59,30 @@ SUPPORT_PHILIPS_JS = ( | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA + | SUPPORT_PLAY + | SUPPORT_PAUSE + | SUPPORT_STOP ) CONF_ON_ACTION = "turn_on_action" -DEFAULT_NAME = "Philips TV" -DEFAULT_API_VERSION = "1" -DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_API_VERSION = 1 -DELAY_ACTION_DEFAULT = 2.0 -DELAY_ACTION_ON = 10.0 - -PREFIX_SEPARATOR = ": " -PREFIX_SOURCE = "Input" -PREFIX_CHANNEL = "Channel" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): cv.string, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_HOST), + cv.deprecated(CONF_NAME), + cv.deprecated(CONF_API_VERSION), + cv.deprecated(CONF_ON_ACTION), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Remove(CONF_NAME): cv.string, + vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): vol.Coerce( + int + ), + vol.Remove(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ), ) @@ -79,78 +90,87 @@ def _inverted(data): return {v: k for k, v in data.items()} -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Philips TV platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - api_version = config.get(CONF_API_VERSION) - turn_on_action = config.get(CONF_ON_ACTION) - - tvapi = PhilipsTV(host, api_version) - domain = __name__.split(".")[-2] - on_script = Script(hass, turn_on_action, name, domain) if turn_on_action else None - - add_entities([PhilipsTVMediaPlayer(tvapi, name, on_script)]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config, + ) + ) -class PhilipsTVMediaPlayer(MediaPlayerEntity): +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Set up the configuration entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + PhilipsTVMediaPlayer( + coordinator, + config_entry.data[CONF_SYSTEM], + config_entry.unique_id, + ) + ] + ) + + +class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): """Representation of a Philips TV exposing the JointSpace API.""" - def __init__(self, tv: PhilipsTV, name: str, on_script: Script): + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + system: Dict[str, Any], + unique_id: str, + ): """Initialize the Philips TV.""" - self._tv = tv - self._name = name + self._tv = coordinator.api + self._coordinator = coordinator self._sources = {} self._channels = {} - self._on_script = on_script self._supports = SUPPORT_PHILIPS_JS - if self._on_script: - self._supports |= SUPPORT_TURN_ON - self._update_task = None + self._system = system + self._unique_id = unique_id + self._state = STATE_OFF + self._media_content_type: Optional[str] = None + self._media_content_id: Optional[str] = None + self._media_title: Optional[str] = None + self._media_channel: Optional[str] = None - def _update_soon(self, delay): + super().__init__(coordinator) + self._update_from_coordinator() + + async def _async_update_soon(self): """Reschedule update task.""" - if self._update_task: - self._update_task() - self._update_task = None - - self.schedule_update_ha_state(force_refresh=False) - - def update_forced(event_time): - self.schedule_update_ha_state(force_refresh=True) - - def update_and_restart(event_time): - update_forced(event_time) - self._update_task = track_time_interval( - self.hass, update_forced, timedelta(seconds=DEFAULT_SCAN_INTERVAL) - ) - - call_later(self.hass, delay, update_and_restart) - - async def async_added_to_hass(self): - """Start running updates once we are added to hass.""" - await self.hass.async_add_executor_job(self._update_soon, 0) + self.async_write_ha_state() + await self.coordinator.async_request_refresh() @property def name(self): """Return the device name.""" - return self._name - - @property - def should_poll(self): - """Device should be polled.""" - return False + return self._system["name"] @property def supported_features(self): """Flag media player features that are supported.""" - return self._supports + supports = self._supports + if self._coordinator.turn_on or ( + self._tv.on and self._tv.powerstate is not None + ): + supports |= SUPPORT_TURN_ON + return supports @property def state(self): """Get the device state. An exception means OFF state.""" if self._tv.on: - return STATE_ON + if self._tv.powerstate == "On" or self._tv.powerstate is None: + return STATE_ON return STATE_OFF @property @@ -163,22 +183,12 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): """List of available input sources.""" return list(self._sources.values()) - def select_source(self, source): + async def async_select_source(self, source): """Set the input source.""" - data = source.split(PREFIX_SEPARATOR, 1) - if data[0] == PREFIX_SOURCE: # Legacy way to set source - source_id = _inverted(self._sources).get(data[1]) - if source_id: - self._tv.setSource(source_id) - elif data[0] == PREFIX_CHANNEL: # Legacy way to set channel - channel_id = _inverted(self._channels).get(data[1]) - if channel_id: - self._tv.setChannel(channel_id) - else: - source_id = _inverted(self._sources).get(source) - if source_id: - self._tv.setSource(source_id) - self._update_soon(DELAY_ACTION_DEFAULT) + source_id = _inverted(self._sources).get(source) + if source_id: + await self._tv.setSource(source_id) + await self._async_update_soon() @property def volume_level(self): @@ -190,134 +200,384 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): """Boolean if volume is currently muted.""" return self._tv.muted - def turn_on(self): + async def async_turn_on(self): """Turn on the device.""" - if self._on_script: - self._on_script.run(context=self._context) - self._update_soon(DELAY_ACTION_ON) + if self._tv.on and self._tv.powerstate: + await self._tv.setPowerState("On") + self._state = STATE_ON + else: + await self._coordinator.turn_on.async_run(self.hass, self._context) + await self._async_update_soon() - def turn_off(self): + async def async_turn_off(self): """Turn off the device.""" - self._tv.sendKey("Standby") - self._tv.on = False - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.sendKey("Standby") + self._state = STATE_OFF + await self._async_update_soon() - def volume_up(self): + async def async_volume_up(self): """Send volume up command.""" - self._tv.sendKey("VolumeUp") - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.sendKey("VolumeUp") + await self._async_update_soon() - def volume_down(self): + async def async_volume_down(self): """Send volume down command.""" - self._tv.sendKey("VolumeDown") - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.sendKey("VolumeDown") + await self._async_update_soon() - def mute_volume(self, mute): + async def async_mute_volume(self, mute): """Send mute command.""" - self._tv.setVolume(None, mute) - self._update_soon(DELAY_ACTION_DEFAULT) + if self._tv.muted != mute: + await self._tv.sendKey("Mute") + await self._async_update_soon() + else: + _LOGGER.debug("Ignoring request when already in expected state") - def set_volume_level(self, volume): + async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._tv.setVolume(volume, self._tv.muted) - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.setVolume(volume, self._tv.muted) + await self._async_update_soon() - def media_previous_track(self): + async def async_media_previous_track(self): """Send rewind command.""" - self._tv.sendKey("Previous") - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.sendKey("Previous") + await self._async_update_soon() - def media_next_track(self): + async def async_media_next_track(self): """Send fast forward command.""" - self._tv.sendKey("Next") - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.sendKey("Next") + await self._async_update_soon() + + async def async_media_play_pause(self): + """Send pause command to media player.""" + if self._tv.quirk_playpause_spacebar: + await self._tv.sendUnicode(" ") + else: + await self._tv.sendKey("PlayPause") + await self._async_update_soon() + + async def async_media_play(self): + """Send pause command to media player.""" + await self._tv.sendKey("Play") + await self._async_update_soon() + + async def async_media_pause(self): + """Send play command to media player.""" + await self._tv.sendKey("Pause") + await self._async_update_soon() + + async def async_media_stop(self): + """Send play command to media player.""" + await self._tv.sendKey("Stop") + await self._async_update_soon() @property def media_channel(self): """Get current channel if it's a channel.""" - if self.media_content_type == MEDIA_TYPE_CHANNEL: - return self._channels.get(self._tv.channel_id) - return None + return self._media_channel @property def media_title(self): """Title of current playing media.""" - if self.media_content_type == MEDIA_TYPE_CHANNEL: - return self._channels.get(self._tv.channel_id) - return self._sources.get(self._tv.source_id) + return self._media_title @property def media_content_type(self): """Return content type of playing media.""" - if self._tv.source_id == "tv" or self._tv.source_id == "11": - return MEDIA_TYPE_CHANNEL - if self._tv.source_id is None and self._tv.channels: - return MEDIA_TYPE_CHANNEL - return None + return self._media_content_type @property def media_content_id(self): """Content type of current playing media.""" - if self.media_content_type == MEDIA_TYPE_CHANNEL: - return self._channels.get(self._tv.channel_id) + return self._media_content_id + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self._media_content_id and self._media_content_type in ( + MEDIA_CLASS_APP, + MEDIA_CLASS_CHANNEL, + ): + return self.get_browse_image_url( + self._media_content_type, self._media_content_id, media_image_id=None + ) return None @property - def device_state_attributes(self): - """Return the state attributes.""" - return {"channel_list": list(self._channels.values())} + def app_id(self): + """ID of the current running app.""" + return self._tv.application_id - def play_media(self, media_type, media_id, **kwargs): + @property + def app_name(self): + """Name of the current running app.""" + app = self._tv.applications.get(self._tv.application_id) + if app: + return app.get("label") + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TV + + @property + def unique_id(self): + """Return unique identifier if known.""" + return self._unique_id + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "name": self._system["name"], + "identifiers": { + (DOMAIN, self._unique_id), + }, + "model": self._system.get("model"), + "manufacturer": "Philips", + "sw_version": self._system.get("softwareversion"), + } + + async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) if media_type == MEDIA_TYPE_CHANNEL: - channel_id = _inverted(self._channels).get(media_id) + list_id, _, channel_id = media_id.partition("/") if channel_id: - self._tv.setChannel(channel_id) - self._update_soon(DELAY_ACTION_DEFAULT) + await self._tv.setChannel(channel_id, list_id) + await self._async_update_soon() else: _LOGGER.error("Unable to find channel <%s>", media_id) + elif media_type == MEDIA_TYPE_APP: + app = self._tv.applications.get(media_id) + if app: + await self._tv.setApplication(app["intent"]) + await self._async_update_soon() + else: + _LOGGER.error("Unable to find application <%s>", media_id) else: _LOGGER.error("Unsupported media type <%s>", media_type) - async def async_browse_media(self, media_content_type=None, media_content_id=None): - """Implement the websocket media browsing helper.""" - if media_content_id not in (None, ""): - raise BrowseError( - f"Media not found: {media_content_type} / {media_content_id}" - ) + async def async_browse_media_channels(self, expanded): + """Return channel media objects.""" + if expanded: + children = [ + BrowseMedia( + title=channel.get("name", f"Channel: {channel_id}"), + media_class=MEDIA_CLASS_CHANNEL, + media_content_id=f"alltv/{channel_id}", + media_content_type=MEDIA_TYPE_CHANNEL, + can_play=True, + can_expand=False, + thumbnail=self.get_browse_image_url( + MEDIA_TYPE_APP, channel_id, media_image_id=None + ), + ) + for channel_id, channel in self._tv.channels.items() + ] + else: + children = None return BrowseMedia( title="Channels", media_class=MEDIA_CLASS_DIRECTORY, - media_content_id="", + media_content_id="channels", media_content_type=MEDIA_TYPE_CHANNELS, + children_media_class=MEDIA_TYPE_CHANNEL, + can_play=False, + can_expand=True, + children=children, + ) + + async def async_browse_media_favorites(self, list_id, expanded): + """Return channel media objects.""" + if expanded: + favorites = await self._tv.getFavoriteList(list_id) + if favorites: + + def get_name(channel): + channel_data = self._tv.channels.get(str(channel["ccid"])) + if channel_data: + return channel_data["name"] + return f"Channel: {channel['ccid']}" + + children = [ + BrowseMedia( + title=get_name(channel), + media_class=MEDIA_CLASS_CHANNEL, + media_content_id=f"{list_id}/{channel['ccid']}", + media_content_type=MEDIA_TYPE_CHANNEL, + can_play=True, + can_expand=False, + thumbnail=self.get_browse_image_url( + MEDIA_TYPE_APP, channel, media_image_id=None + ), + ) + for channel in favorites + ] + else: + children = None + else: + children = None + + favorite = self._tv.favorite_lists[list_id] + return BrowseMedia( + title=favorite.get("name", f"Favorites {list_id}"), + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=f"favorites/{list_id}", + media_content_type=MEDIA_TYPE_CHANNELS, + children_media_class=MEDIA_TYPE_CHANNEL, + can_play=False, + can_expand=True, + children=children, + ) + + async def async_browse_media_applications(self, expanded): + """Return application media objects.""" + if expanded: + children = [ + BrowseMedia( + title=application["label"], + media_class=MEDIA_CLASS_APP, + media_content_id=application_id, + media_content_type=MEDIA_TYPE_APP, + can_play=True, + can_expand=False, + thumbnail=self.get_browse_image_url( + MEDIA_TYPE_APP, application_id, media_image_id=None + ), + ) + for application_id, application in self._tv.applications.items() + ] + else: + children = None + + return BrowseMedia( + title="Applications", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="applications", + media_content_type=MEDIA_TYPE_APPS, + children_media_class=MEDIA_TYPE_APP, + can_play=False, + can_expand=True, + children=children, + ) + + async def async_browse_media_favorite_lists(self, expanded): + """Return favorite media objects.""" + if self._tv.favorite_lists and expanded: + children = [ + await self.async_browse_media_favorites(list_id, False) + for list_id in self._tv.favorite_lists + ] + else: + children = None + + return BrowseMedia( + title="Favorites", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="favorite_lists", + media_content_type=MEDIA_TYPE_CHANNELS, + children_media_class=MEDIA_TYPE_CHANNEL, + can_play=False, + can_expand=True, + children=children, + ) + + async def async_browse_media_root(self): + """Return root media objects.""" + + return BrowseMedia( + title="Library", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="", can_play=False, can_expand=True, children=[ - BrowseMedia( - title=channel, - media_class=MEDIA_CLASS_CHANNEL, - media_content_id=channel, - media_content_type=MEDIA_TYPE_CHANNEL, - can_play=True, - can_expand=False, - ) - for channel in self._channels.values() + await self.async_browse_media_channels(False), + await self.async_browse_media_applications(False), + await self.async_browse_media_favorite_lists(False), ], ) - def update(self): - """Get the latest data and update device state.""" - self._tv.update() + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if not self._tv.on: + raise BrowseError("Can't browse when tv is turned off") + + if media_content_id in (None, ""): + return await self.async_browse_media_root() + path = media_content_id.partition("/") + if path[0] == "channels": + return await self.async_browse_media_channels(True) + if path[0] == "applications": + return await self.async_browse_media_applications(True) + if path[0] == "favorite_lists": + return await self.async_browse_media_favorite_lists(True) + if path[0] == "favorites": + return await self.async_browse_media_favorites(path[2], True) + + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + + async def async_get_browse_image( + self, media_content_type, media_content_id, media_image_id=None + ): + """Serve album art. Returns (content, content_type).""" + try: + if media_content_type == MEDIA_TYPE_APP and media_content_id: + return await self._tv.getApplicationIcon(media_content_id) + if media_content_type == MEDIA_TYPE_CHANNEL and media_content_id: + return await self._tv.getChannelLogo(media_content_id) + except ConnectionFailure: + _LOGGER.warning("Failed to fetch image") + return None, None + + async def async_get_media_image(self): + """Serve album art. Returns (content, content_type).""" + return await self.async_get_browse_image( + self.media_content_type, self.media_content_id, None + ) + + @callback + def _update_from_coordinator(self): + + if self._tv.on: + if self._tv.powerstate in ("Standby", "StandbyKeep"): + self._state = STATE_OFF + else: + self._state = STATE_ON + else: + self._state = STATE_OFF self._sources = { srcid: source.get("name") or f"Source {srcid}" for srcid, source in (self._tv.sources or {}).items() } - self._channels = { - chid: channel.get("name") or f"Channel {chid}" - for chid, channel in (self._tv.channels or {}).items() - } + if self._tv.channel_active: + self._media_content_type = MEDIA_TYPE_CHANNEL + self._media_content_id = f"all/{self._tv.channel_id}" + self._media_title = self._tv.channels.get(self._tv.channel_id, {}).get( + "name" + ) + self._media_channel = self._media_title + elif self._tv.application_id: + self._media_content_type = MEDIA_TYPE_APP + self._media_content_id = self._tv.application_id + self._media_title = self._tv.applications.get( + self._tv.application_id, {} + ).get("label") + self._media_channel = None + else: + self._media_content_type = None + self._media_content_id = None + self._media_title = self._sources.get(self._tv.source_id) + self._media_channel = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_coordinator() + super()._handle_coordinator_update() diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json new file mode 100644 index 00000000000..df65d453f2b --- /dev/null +++ b/homeassistant/components/philips_js/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_version": "API Version" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "pairing_failure": "Unable to pair: {error_id}", + "invalid_pin": "Invalid PIN" +}, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/components/philips_js/translations/ca.json b/homeassistant/components/philips_js/translations/ca.json new file mode 100644 index 00000000000..980bb6800e1 --- /dev/null +++ b/homeassistant/components/philips_js/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_pin": "PIN inv\u00e0lid", + "pairing_failure": "No s'ha pogut vincular: {error_id}", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_version": "Versi\u00f3 de l'API", + "host": "Amfitri\u00f3" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Es demani que el dispositiu s'engegui" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/cs.json b/homeassistant/components/philips_js/translations/cs.json new file mode 100644 index 00000000000..a39944a8dba --- /dev/null +++ b/homeassistant/components/philips_js/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_version": "Verze API", + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/de.json b/homeassistant/components/philips_js/translations/de.json new file mode 100644 index 00000000000..f59a17bce49 --- /dev/null +++ b/homeassistant/components/philips_js/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_pin": "Ung\u00fcltige PIN", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_version": "API-Version", + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/en.json b/homeassistant/components/philips_js/translations/en.json new file mode 100644 index 00000000000..65d4f417b9f --- /dev/null +++ b/homeassistant/components/philips_js/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_pin": "Invalid PIN", + "pairing_failure": "Unable to pair: {error_id}", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_version": "API Version", + "host": "Host" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Device is requested to turn on" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/es.json b/homeassistant/components/philips_js/translations/es.json new file mode 100644 index 00000000000..d4476f29981 --- /dev/null +++ b/homeassistant/components/philips_js/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_version": "Versi\u00f3n del API", + "host": "Host" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Se solicita al dispositivo que se encienda" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/et.json b/homeassistant/components/philips_js/translations/et.json new file mode 100644 index 00000000000..9953df9c272 --- /dev/null +++ b/homeassistant/components/philips_js/translations/et.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_pin": "Vale PIN kood", + "pairing_failure": "Sidumine nurjus: {error_id}", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_version": "API versioon", + "host": "Host" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Seadmel palutakse sisse l\u00fclituda" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/fr.json b/homeassistant/components/philips_js/translations/fr.json new file mode 100644 index 00000000000..25c28edcf1d --- /dev/null +++ b/homeassistant/components/philips_js/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", + "invalid_pin": "NIP invalide", + "pairing_failure": "Association impossible: {error_id}", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_version": "Version de l'API", + "host": "H\u00f4te" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Il a \u00e9t\u00e9 demand\u00e9 \u00e0 l'appareil de s'allumer" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/he.json b/homeassistant/components/philips_js/translations/he.json new file mode 100644 index 00000000000..04648fe5845 --- /dev/null +++ b/homeassistant/components/philips_js/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "pairing_failure": "\u05e6\u05d9\u05de\u05d5\u05d3 \u05e0\u05db\u05e9\u05dc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/it.json b/homeassistant/components/philips_js/translations/it.json new file mode 100644 index 00000000000..216248d8eac --- /dev/null +++ b/homeassistant/components/philips_js/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_version": "Versione API", + "host": "Host" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Si richiede l'accensione del dispositivo" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/ko.json b/homeassistant/components/philips_js/translations/ko.json new file mode 100644 index 00000000000..85281856809 --- /dev/null +++ b/homeassistant/components/philips_js/translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/nl.json b/homeassistant/components/philips_js/translations/nl.json new file mode 100644 index 00000000000..23cd7e47043 --- /dev/null +++ b/homeassistant/components/philips_js/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_version": "API Versie", + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/no.json b/homeassistant/components/philips_js/translations/no.json new file mode 100644 index 00000000000..a9c647a644b --- /dev/null +++ b/homeassistant/components/philips_js/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_pin": "Ugyldig PIN", + "pairing_failure": "Kan ikke parre: {error_id}", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_version": "API-versjon", + "host": "Vert" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Enheten blir bedt om \u00e5 sl\u00e5 p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/pl.json b/homeassistant/components/philips_js/translations/pl.json new file mode 100644 index 00000000000..27c088350c4 --- /dev/null +++ b/homeassistant/components/philips_js/translations/pl.json @@ -0,0 +1,24 @@ +{ + "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": { + "api_version": "Wersja API", + "host": "Nazwa hosta lub adres IP" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/ru.json b/homeassistant/components/philips_js/translations/ru.json new file mode 100644 index 00000000000..83511ff246a --- /dev/null +++ b/homeassistant/components/philips_js/translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "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_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434.", + "pairing_failure": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435: {error_id}.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "api_version": "\u0412\u0435\u0440\u0441\u0438\u044f API", + "host": "\u0425\u043e\u0441\u0442" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/zh-Hant.json b/homeassistant/components/philips_js/translations/zh-Hant.json new file mode 100644 index 00000000000..13bfd52e980 --- /dev/null +++ b/homeassistant/components/philips_js/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_pin": "PIN \u78bc\u7121\u6548", + "pairing_failure": "\u7121\u6cd5\u914d\u5c0d\uff1a{error_id}", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_version": "API \u7248\u672c", + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "\u88dd\u7f6e\u5fc5\u9808\u70ba\u958b\u555f\u72c0\u614b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/de.json b/homeassistant/components/pi_hole/translations/de.json index 34198fcfebe..6d9518490d5 100644 --- a/homeassistant/components/pi_hole/translations/de.json +++ b/homeassistant/components/pi_hole/translations/de.json @@ -7,6 +7,11 @@ "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { + "api_key": { + "data": { + "api_key": "API-Schl\u00fcssel" + } + }, "user": { "data": { "api_key": "API-Schl\u00fcssel", @@ -15,6 +20,7 @@ "name": "Name", "port": "Port", "ssl": "Nutzt ein SSL-Zertifikat", + "statistics_only": "Nur Statistiken", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" } } diff --git a/homeassistant/components/pi_hole/translations/fr.json b/homeassistant/components/pi_hole/translations/fr.json index 1ccc5ac7d76..152fb0f3def 100644 --- a/homeassistant/components/pi_hole/translations/fr.json +++ b/homeassistant/components/pi_hole/translations/fr.json @@ -7,6 +7,11 @@ "cannot_connect": "Connexion impossible" }, "step": { + "api_key": { + "data": { + "api_key": "Clef d'API" + } + }, "user": { "data": { "api_key": "Cl\u00e9 d'API", @@ -15,6 +20,7 @@ "name": "Nom", "port": "Port", "ssl": "Utiliser SSL", + "statistics_only": "Statistiques uniquement", "verify_ssl": "V\u00e9rifier le certificat SSL" } } diff --git a/homeassistant/components/pi_hole/translations/ko.json b/homeassistant/components/pi_hole/translations/ko.json index 4653cc8564d..7261742b2a6 100644 --- a/homeassistant/components/pi_hole/translations/ko.json +++ b/homeassistant/components/pi_hole/translations/ko.json @@ -7,6 +7,11 @@ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { + "api_key": { + "data": { + "api_key": "API \ud0a4" + } + }, "user": { "data": { "api_key": "API \ud0a4", @@ -14,8 +19,8 @@ "location": "\uc704\uce58", "name": "\uc774\ub984", "port": "\ud3ec\ud2b8", - "ssl": "SSL \uc0ac\uc6a9", - "verify_ssl": "SSL \uc778\uc99d\uc11c \uac80\uc99d" + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" } } } diff --git a/homeassistant/components/pi_hole/translations/nl.json b/homeassistant/components/pi_hole/translations/nl.json index 24da024acae..156a248a80b 100644 --- a/homeassistant/components/pi_hole/translations/nl.json +++ b/homeassistant/components/pi_hole/translations/nl.json @@ -7,6 +7,11 @@ "cannot_connect": "Kon niet verbinden" }, "step": { + "api_key": { + "data": { + "api_key": "API-sleutel" + } + }, "user": { "data": { "api_key": "API-sleutel", @@ -15,6 +20,7 @@ "name": "Naam", "port": "Poort", "ssl": "Maakt gebruik van een SSL-certificaat", + "statistics_only": "Alleen statistieken", "verify_ssl": "SSL-certificaat verifi\u00ebren" } } diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index b365c7e0081..2cf97d5fd9a 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -1,11 +1,34 @@ -"""Support for Plaato Airlock.""" +"""Support for Plaato devices.""" + +import asyncio +from datetime import timedelta import logging from aiohttp import web +from pyplaato.models.airlock import PlaatoAirlock +from pyplaato.plaato import ( + ATTR_ABV, + ATTR_BATCH_VOLUME, + ATTR_BPM, + ATTR_BUBBLES, + ATTR_CO2_VOLUME, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_OG, + ATTR_SG, + ATTR_TEMP, + ATTR_TEMP_UNIT, + ATTR_VOLUME_UNIT, + Plaato, + PlaatoDeviceType, +) import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_TOKEN, CONF_WEBHOOK_ID, HTTP_OK, TEMP_CELSIUS, @@ -13,31 +36,33 @@ from homeassistant.const import ( VOLUME_GALLONS, VOLUME_LITERS, ) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import ( + CONF_DEVICE_NAME, + CONF_DEVICE_TYPE, + CONF_USE_WEBHOOK, + COORDINATOR, + DEFAULT_SCAN_INTERVAL, + DEVICE, + DEVICE_ID, + DEVICE_NAME, + DEVICE_TYPE, + DOMAIN, + PLATFORMS, + SENSOR_DATA, + UNDO_UPDATE_LISTENER, +) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["webhook"] -PLAATO_DEVICE_SENSORS = "sensors" -PLAATO_DEVICE_ATTRS = "attrs" - -ATTR_DEVICE_ID = "device_id" -ATTR_DEVICE_NAME = "device_name" -ATTR_TEMP_UNIT = "temp_unit" -ATTR_VOLUME_UNIT = "volume_unit" -ATTR_BPM = "bpm" -ATTR_TEMP = "temp" -ATTR_SG = "sg" -ATTR_OG = "og" -ATTR_BUBBLES = "bubbles" -ATTR_ABV = "abv" -ATTR_CO2_VOLUME = "co2_volume" -ATTR_BATCH_VOLUME = "batch_volume" - SENSOR_UPDATE = f"{DOMAIN}_sensor_update" SENSOR_DATA_KEY = f"{DOMAIN}.{SENSOR}" @@ -60,31 +85,124 @@ WEBHOOK_SCHEMA = vol.Schema( ) -async def async_setup(hass, hass_config): +async def async_setup(hass: HomeAssistant, config: dict): """Set up the Plaato component.""" + hass.data.setdefault(DOMAIN, {}) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Configure based on config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + use_webhook = entry.data[CONF_USE_WEBHOOK] + + if use_webhook: + async_setup_webhook(hass, entry) + else: + await async_setup_coordinator(hass, entry) + + for platform in PLATFORMS: + if entry.options.get(platform, True): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +@callback +def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry): + """Init webhook based on config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_register(DOMAIN, "Plaato", webhook_id, handle_webhook) + device_name = entry.data[CONF_DEVICE_NAME] - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, SENSOR)) + _set_entry_data(entry, hass) - return True + hass.components.webhook.async_register( + DOMAIN, f"{DOMAIN}.{device_name}", webhook_id, handle_webhook + ) -async def async_unload_entry(hass, entry): +async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry): + """Init auth token based on config entry.""" + auth_token = entry.data[CONF_TOKEN] + device_type = entry.data[CONF_DEVICE_TYPE] + + if entry.options.get(CONF_SCAN_INTERVAL): + update_interval = timedelta(minutes=entry.options[CONF_SCAN_INTERVAL]) + else: + update_interval = timedelta(minutes=DEFAULT_SCAN_INTERVAL) + + coordinator = PlaatoCoordinator(hass, auth_token, device_type, update_interval) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + _set_entry_data(entry, hass, coordinator, auth_token) + + for platform in PLATFORMS: + if entry.options.get(platform, True): + coordinator.platforms.append(platform) + + +def _set_entry_data(entry, hass, coordinator=None, device_id=None): + device = { + DEVICE_NAME: entry.data[CONF_DEVICE_NAME], + DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], + DEVICE_ID: device_id, + } + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR: coordinator, + DEVICE: device, + SENSOR_DATA: None, + UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), + } + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - hass.data[SENSOR_DATA_KEY]() + use_webhook = entry.data[CONF_USE_WEBHOOK] + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - await hass.config_entries.async_forward_entry_unload(entry, SENSOR) - return True + if use_webhook: + return await async_unload_webhook(hass, entry) + + return await async_unload_coordinator(hass, entry) + + +async def async_unload_webhook(hass: HomeAssistant, entry: ConfigEntry): + """Unload webhook based entry.""" + if entry.data[CONF_WEBHOOK_ID] is not None: + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return await async_unload_platforms(hass, entry, PLATFORMS) + + +async def async_unload_coordinator(hass: HomeAssistant, entry: ConfigEntry): + """Unload auth token based entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + return await async_unload_platforms(hass, entry, coordinator.platforms) + + +async def async_unload_platforms(hass: HomeAssistant, entry: ConfigEntry, platforms): + """Unload platforms.""" + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in platforms + ] + ) + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + + return unloaded + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) async def handle_webhook(hass, webhook_id, request): @@ -96,31 +214,9 @@ async def handle_webhook(hass, webhook_id, request): return device_id = _device_id(data) + sensor_data = PlaatoAirlock.from_web_hook(data) - attrs = { - ATTR_DEVICE_NAME: data.get(ATTR_DEVICE_NAME), - ATTR_DEVICE_ID: data.get(ATTR_DEVICE_ID), - ATTR_TEMP_UNIT: data.get(ATTR_TEMP_UNIT), - ATTR_VOLUME_UNIT: data.get(ATTR_VOLUME_UNIT), - } - - sensors = { - ATTR_TEMP: data.get(ATTR_TEMP), - ATTR_BPM: data.get(ATTR_BPM), - ATTR_SG: data.get(ATTR_SG), - ATTR_OG: data.get(ATTR_OG), - ATTR_ABV: data.get(ATTR_ABV), - ATTR_CO2_VOLUME: data.get(ATTR_CO2_VOLUME), - ATTR_BATCH_VOLUME: data.get(ATTR_BATCH_VOLUME), - ATTR_BUBBLES: data.get(ATTR_BUBBLES), - } - - hass.data[DOMAIN][device_id] = { - PLAATO_DEVICE_ATTRS: attrs, - PLAATO_DEVICE_SENSORS: sensors, - } - - async_dispatcher_send(hass, SENSOR_UPDATE, device_id) + async_dispatcher_send(hass, SENSOR_UPDATE, *(device_id, sensor_data)) return web.Response(text=f"Saving status for {device_id}", status=HTTP_OK) @@ -128,3 +224,35 @@ async def handle_webhook(hass, webhook_id, request): def _device_id(data): """Return name of device sensor.""" return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}" + + +class PlaatoCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass, + auth_token, + device_type: PlaatoDeviceType, + update_interval: timedelta, + ): + """Initialize.""" + self.api = Plaato(auth_token=auth_token) + self.hass = hass + self.device_type = device_type + self.platforms = [] + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self): + """Update data via library.""" + data = await self.api.get_data( + session=aiohttp_client.async_get_clientsession(self.hass), + device_type=self.device_type, + ) + return data diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py new file mode 100644 index 00000000000..27150692d6f --- /dev/null +++ b/homeassistant/components/plaato/binary_sensor.py @@ -0,0 +1,54 @@ +"""Support for Plaato Airlock sensors.""" + +import logging + +from pyplaato.plaato import PlaatoKeg + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, +) + +from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN +from .entity import PlaatoEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Plaato from a config entry.""" + + if config_entry.data[CONF_USE_WEBHOOK]: + return + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities( + PlaatoBinarySensor( + hass.data[DOMAIN][config_entry.entry_id], + sensor_type, + coordinator, + ) + for sensor_type in coordinator.data.binary_sensors + ) + + +class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity): + """Representation of a Binary Sensor.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self._coordinator is not None: + return self._coordinator.data.binary_sensors.get(self._sensor_type) + return False + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + if self._coordinator is None: + return None + if self._sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: + return DEVICE_CLASS_PROBLEM + if self._sensor_type is PlaatoKeg.Pins.POURING: + return DEVICE_CLASS_OPENING diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 3c616c822fb..8dbf6d50fca 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -1,10 +1,223 @@ -"""Config flow for GPSLogger.""" -from homeassistant.helpers import config_entry_flow +"""Config flow for Plaato.""" +import logging -from .const import DOMAIN +from pyplaato.plaato import PlaatoDeviceType +import voluptuous as vol -config_entry_flow.register_webhook_flow( - DOMAIN, - "Webhook", - {"docs_url": "https://www.home-assistant.io/integrations/plaato/"}, +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_CLOUDHOOK, + CONF_DEVICE_NAME, + CONF_DEVICE_TYPE, + CONF_USE_WEBHOOK, + DEFAULT_SCAN_INTERVAL, + DOCS_URL, + PLACEHOLDER_DEVICE_NAME, + PLACEHOLDER_DEVICE_TYPE, + PLACEHOLDER_DOCS_URL, + PLACEHOLDER_WEBHOOK_URL, ) +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__package__) + + +class PlaatoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handles a Plaato config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self._init_info = {} + + async def async_step_user(self, user_input=None): + """Handle user step.""" + + if user_input is not None: + self._init_info[CONF_DEVICE_TYPE] = PlaatoDeviceType( + user_input[CONF_DEVICE_TYPE] + ) + self._init_info[CONF_DEVICE_NAME] = user_input[CONF_DEVICE_NAME] + + return await self.async_step_api_method() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE_NAME, + default=self._init_info.get(CONF_DEVICE_NAME, None), + ): str, + vol.Required( + CONF_DEVICE_TYPE, + default=self._init_info.get(CONF_DEVICE_TYPE, None), + ): vol.In(list(PlaatoDeviceType)), + } + ), + ) + + async def async_step_api_method(self, user_input=None): + """Handle device type step.""" + + device_type = self._init_info[CONF_DEVICE_TYPE] + + if user_input is not None: + token = user_input.get(CONF_TOKEN, None) + use_webhook = user_input.get(CONF_USE_WEBHOOK, False) + + if not token and not use_webhook: + errors = {"base": PlaatoConfigFlow._get_error(device_type)} + return await self._show_api_method_form(device_type, errors) + + self._init_info[CONF_USE_WEBHOOK] = use_webhook + self._init_info[CONF_TOKEN] = token + return await self.async_step_webhook() + + return await self._show_api_method_form(device_type) + + async def async_step_webhook(self, user_input=None): + """Validate config step.""" + + use_webhook = self._init_info[CONF_USE_WEBHOOK] + + if use_webhook and user_input is None: + webhook_id, webhook_url, cloudhook = await self._get_webhook_id() + self._init_info[CONF_WEBHOOK_ID] = webhook_id + self._init_info[CONF_CLOUDHOOK] = cloudhook + + return self.async_show_form( + step_id="webhook", + description_placeholders={ + PLACEHOLDER_WEBHOOK_URL: webhook_url, + PLACEHOLDER_DOCS_URL: DOCS_URL, + }, + ) + + return await self._async_create_entry() + + async def _async_create_entry(self): + """Create the entry step.""" + + webhook_id = self._init_info.get(CONF_WEBHOOK_ID, None) + auth_token = self._init_info[CONF_TOKEN] + device_name = self._init_info[CONF_DEVICE_NAME] + device_type = self._init_info[CONF_DEVICE_TYPE] + + unique_id = auth_token if auth_token else webhook_id + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device_type.name, + data=self._init_info, + description_placeholders={ + PLACEHOLDER_DEVICE_TYPE: device_type.name, + PLACEHOLDER_DEVICE_NAME: device_name, + }, + ) + + async def _show_api_method_form( + self, device_type: PlaatoDeviceType, errors: dict = None + ): + data_schema = vol.Schema({vol.Optional(CONF_TOKEN, default=""): str}) + + if device_type == PlaatoDeviceType.Airlock: + data_schema = data_schema.extend( + {vol.Optional(CONF_USE_WEBHOOK, default=False): bool} + ) + + return self.async_show_form( + step_id="api_method", + data_schema=data_schema, + errors=errors, + description_placeholders={PLACEHOLDER_DEVICE_TYPE: device_type.name}, + ) + + async def _get_webhook_id(self): + """Generate webhook ID.""" + webhook_id = self.hass.components.webhook.async_generate_id() + if self.hass.components.cloud.async_active_subscription(): + webhook_url = await self.hass.components.cloud.async_create_cloudhook( + webhook_id + ) + cloudhook = True + else: + webhook_url = self.hass.components.webhook.async_generate_url(webhook_id) + cloudhook = False + + return webhook_id, webhook_url, cloudhook + + @staticmethod + def _get_error(device_type: PlaatoDeviceType): + if device_type == PlaatoDeviceType.Airlock: + return "no_api_method" + return "no_auth_token" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return PlaatoOptionsFlowHandler(config_entry) + + +class PlaatoOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Plaato options.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize domain options flow.""" + super().__init__() + + self._config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + use_webhook = self._config_entry.data.get(CONF_USE_WEBHOOK, False) + if use_webhook: + return await self.async_step_webhook() + + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self._config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): cv.positive_int + } + ), + ) + + async def async_step_webhook(self, user_input=None): + """Manage the options for webhook device.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + webhook_id = self._config_entry.data.get(CONF_WEBHOOK_ID, None) + webhook_url = ( + "" + if webhook_id is None + else self.hass.components.webhook.async_generate_url(webhook_id) + ) + + return self.async_show_form( + step_id="webhook", + description_placeholders={PLACEHOLDER_WEBHOOK_URL: webhook_url}, + ) diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index cbe8fcd2b6d..1700b803775 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -1,3 +1,36 @@ -"""Const for GPSLogger.""" +"""Const for Plaato.""" +from datetime import timedelta DOMAIN = "plaato" +PLAATO_DEVICE_SENSORS = "sensors" +PLAATO_DEVICE_ATTRS = "attrs" +SENSOR_SIGNAL = f"{DOMAIN}_%s_%s" + +CONF_USE_WEBHOOK = "use_webhook" +CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_NAME = "device_name" +CONF_CLOUDHOOK = "cloudhook" +PLACEHOLDER_WEBHOOK_URL = "webhook_url" +PLACEHOLDER_DOCS_URL = "docs_url" +PLACEHOLDER_DEVICE_TYPE = "device_type" +PLACEHOLDER_DEVICE_NAME = "device_name" +DOCS_URL = "https://www.home-assistant.io/integrations/plaato/" +PLATFORMS = ["sensor", "binary_sensor"] +SENSOR_DATA = "sensor_data" +COORDINATOR = "coordinator" +DEVICE = "device" +DEVICE_NAME = "device_name" +DEVICE_TYPE = "device_type" +DEVICE_ID = "device_id" +UNDO_UPDATE_LISTENER = "undo_update_listener" +DEFAULT_SCAN_INTERVAL = 5 +MIN_UPDATE_INTERVAL = timedelta(minutes=1) + +DEVICE_STATE_ATTRIBUTES = { + "beer_name": "beer_name", + "keg_date": "keg_date", + "mode": "mode", + "original_gravity": "original_gravity", + "final_gravity": "final_gravity", + "alcohol_by_volume": "alcohol_by_volume", +} diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py new file mode 100644 index 00000000000..7cb1a77a9fb --- /dev/null +++ b/homeassistant/components/plaato/entity.py @@ -0,0 +1,109 @@ +"""PlaatoEntity class.""" +from pyplaato.models.device import PlaatoDevice + +from homeassistant.helpers import entity + +from .const import ( + DEVICE, + DEVICE_ID, + DEVICE_NAME, + DEVICE_STATE_ATTRIBUTES, + DEVICE_TYPE, + DOMAIN, + SENSOR_DATA, + SENSOR_SIGNAL, +) + + +class PlaatoEntity(entity.Entity): + """Representation of a Plaato Entity.""" + + def __init__(self, data, sensor_type, coordinator=None): + """Initialize the sensor.""" + self._coordinator = coordinator + self._entry_data = data + self._sensor_type = sensor_type + self._device_id = data[DEVICE][DEVICE_ID] + self._device_type = data[DEVICE][DEVICE_TYPE] + self._device_name = data[DEVICE][DEVICE_NAME] + self._state = 0 + + @property + def _attributes(self) -> dict: + return PlaatoEntity._to_snake_case(self._sensor_data.attributes) + + @property + def _sensor_name(self) -> str: + return self._sensor_data.get_sensor_name(self._sensor_type) + + @property + def _sensor_data(self) -> PlaatoDevice: + if self._coordinator: + return self._coordinator.data + return self._entry_data[SENSOR_DATA] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return f"{self._device_id}_{self._sensor_type}" + + @property + def device_info(self): + """Get device info.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": "Plaato", + "model": self._device_type, + } + + if self._sensor_data.firmware_version != "": + device_info["sw_version"] = self._sensor_data.firmware_version + + return device_info + + @property + def device_state_attributes(self): + """Return the state attributes of the monitored installation.""" + if self._attributes: + return { + attr_key: self._attributes[plaato_key] + for attr_key, plaato_key in DEVICE_STATE_ATTRIBUTES.items() + if plaato_key in self._attributes + and self._attributes[plaato_key] is not None + } + + @property + def available(self): + """Return if sensor is available.""" + if self._coordinator is not None: + return self._coordinator.last_update_success + return True + + @property + def should_poll(self): + """Return the polling state.""" + return False + + async def async_added_to_hass(self): + """When entity is added to hass.""" + if self._coordinator is not None: + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + else: + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SENSOR_SIGNAL % (self._device_id, self._sensor_type), + self.async_write_ha_state, + ) + ) + + @staticmethod + def _to_snake_case(dictionary: dict): + return {k.lower().replace(" ", "_"): v for k, v in dictionary.items()} diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json index 29e104b13ed..e3291e5a229 100644 --- a/homeassistant/components/plaato/manifest.json +++ b/homeassistant/components/plaato/manifest.json @@ -1,8 +1,10 @@ { "domain": "plaato", - "name": "Plaato Airlock", + "name": "Plaato", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plaato", "dependencies": ["webhook"], - "codeowners": ["@JohNan"] + "after_dependencies": ["cloud"], + "codeowners": ["@JohNan"], + "requirements": ["pyplaato==0.0.15"] } diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 3f8034698fd..ae767add18b 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -1,164 +1,83 @@ """Support for Plaato Airlock sensors.""" +from typing import Optional -import logging +from pyplaato.models.device import PlaatoDevice +from pyplaato.plaato import PlaatoKeg -from homeassistant.const import PERCENTAGE +from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity -from . import ( - ATTR_ABV, - ATTR_BATCH_VOLUME, - ATTR_BPM, - ATTR_CO2_VOLUME, - ATTR_TEMP, - ATTR_TEMP_UNIT, - ATTR_VOLUME_UNIT, - DOMAIN as PLAATO_DOMAIN, - PLAATO_DEVICE_ATTRS, - PLAATO_DEVICE_SENSORS, - SENSOR_DATA_KEY, - SENSOR_UPDATE, +from . import ATTR_TEMP, SENSOR_UPDATE +from ...core import callback +from .const import ( + CONF_USE_WEBHOOK, + COORDINATOR, + DEVICE, + DEVICE_ID, + DOMAIN, + SENSOR_DATA, + SENSOR_SIGNAL, ) - -_LOGGER = logging.getLogger(__name__) +from .entity import PlaatoEntity async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Plaato sensor.""" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass, entry, async_add_entities): """Set up Plaato from a config entry.""" - devices = {} + entry_data = hass.data[DOMAIN][entry.entry_id] - def get_device(device_id): - """Get a device.""" - return hass.data[PLAATO_DOMAIN].get(device_id, False) - - def get_device_sensors(device_id): - """Get device sensors.""" - return hass.data[PLAATO_DOMAIN].get(device_id).get(PLAATO_DEVICE_SENSORS) - - async def _update_sensor(device_id): + @callback + async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): """Update/Create the sensors.""" - if device_id not in devices and get_device(device_id): - entities = [] - sensors = get_device_sensors(device_id) + entry_data[SENSOR_DATA] = sensor_data - for sensor_type in sensors: - entities.append(PlaatoSensor(device_id, sensor_type)) - - devices[device_id] = entities - - async_add_entities(entities, True) + if device_id != entry_data[DEVICE][DEVICE_ID]: + entry_data[DEVICE][DEVICE_ID] = device_id + async_add_entities( + [ + PlaatoSensor(entry_data, sensor_type) + for sensor_type in sensor_data.sensors + ] + ) else: - for entity in devices[device_id]: - async_dispatcher_send(hass, f"{PLAATO_DOMAIN}_{entity.unique_id}") + for sensor_type in sensor_data.sensors: + async_dispatcher_send(hass, SENSOR_SIGNAL % (device_id, sensor_type)) - hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( - hass, SENSOR_UPDATE, _update_sensor - ) - - return True - - -class PlaatoSensor(Entity): - """Representation of a Sensor.""" - - def __init__(self, device_id, sensor_type): - """Initialize the sensor.""" - self._device_id = device_id - self._type = sensor_type - self._state = 0 - self._name = f"{device_id} {sensor_type}" - self._attributes = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{PLAATO_DOMAIN} {self._name}" - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._type}" - - @property - def device_info(self): - """Get device info.""" - return { - "identifiers": {(PLAATO_DOMAIN, self._device_id)}, - "name": self._device_id, - "manufacturer": "Plaato", - "model": "Airlock", - } - - def get_sensors(self): - """Get device sensors.""" - return ( - self.hass.data[PLAATO_DOMAIN] - .get(self._device_id) - .get(PLAATO_DEVICE_SENSORS, False) + if entry.data[CONF_USE_WEBHOOK]: + async_dispatcher_connect(hass, SENSOR_UPDATE, _async_update_from_webhook) + else: + coordinator = entry_data[COORDINATOR] + async_add_entities( + PlaatoSensor(entry_data, sensor_type, coordinator) + for sensor_type in coordinator.data.sensors ) - def get_sensors_unit_of_measurement(self, sensor_type): - """Get unit of measurement for sensor of type.""" - return ( - self.hass.data[PLAATO_DOMAIN] - .get(self._device_id) - .get(PLAATO_DEVICE_ATTRS, []) - .get(sensor_type, "") - ) + +class PlaatoSensor(PlaatoEntity): + """Representation of a Plaato Sensor.""" + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + if self._coordinator is not None: + if self._sensor_type == PlaatoKeg.Pins.TEMPERATURE: + return DEVICE_CLASS_TEMPERATURE + if self._sensor_type == ATTR_TEMP: + return DEVICE_CLASS_TEMPERATURE + return None @property def state(self): """Return the state of the sensor.""" - sensors = self.get_sensors() - if sensors is False: - _LOGGER.debug("Device with name %s has no sensors", self.name) - return 0 - - if self._type == ATTR_ABV: - return round(sensors.get(self._type), 2) - if self._type == ATTR_TEMP: - return round(sensors.get(self._type), 1) - if self._type == ATTR_CO2_VOLUME: - return round(sensors.get(self._type), 2) - return sensors.get(self._type) - - @property - def device_state_attributes(self): - """Return the state attributes of the monitored installation.""" - if self._attributes is not None: - return self._attributes + return self._sensor_data.sensors.get(self._sensor_type) @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self._type == ATTR_TEMP: - return self.get_sensors_unit_of_measurement(ATTR_TEMP_UNIT) - if self._type == ATTR_BATCH_VOLUME or self._type == ATTR_CO2_VOLUME: - return self.get_sensors_unit_of_measurement(ATTR_VOLUME_UNIT) - if self._type == ATTR_BPM: - return "bpm" - if self._type == ATTR_ABV: - return PERCENTAGE - - return "" - - @property - def should_poll(self): - """Return the polling state.""" - return False - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - f"{PLAATO_DOMAIN}_{self.unique_id}", self.async_write_ha_state - ) - ) + return self._sensor_data.get_unit_of_measurement(self._sensor_type) diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json index 087cee13683..852ecc88dde 100644 --- a/homeassistant/components/plaato/strings.json +++ b/homeassistant/components/plaato/strings.json @@ -2,16 +2,53 @@ "config": { "step": { "user": { - "title": "Set up the Plaato Webhook", - "description": "[%key:common::config_flow::description::confirm_setup%]" + "title": "Set up the Plaato devices", + "description": "[%key:common::config_flow::description::confirm_setup%]", + "data": { + "device_name": "Name your device", + "device_type": "Type of Plaato device" + } + }, + "api_method": { + "title": "Select API method", + "description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank", + "data": { + "use_webhook": "Use webhook", + "token": "Paste Auth Token here" + } + }, + "webhook": { + "title": "Webhook to use", + "description": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." } }, + "error": { + "invalid_webhook_device": "You have selected a device that doesn't not support sending data to a webhook. It is only available for the Airlock", + "no_auth_token": "You need to add an auth token", + "no_api_method": "You need to add an auth token or select webhook" + }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" + "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + "default": "Your Plaato {device_type} with name **{device_name}** was successfully setup!" + } + }, + "options": { + "step": { + "webhook": { + "title": "Options for Plaato Airlock", + "description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n" + }, + "user": { + "title": "Options for Plaato", + "description": "Set the update interval (minutes)", + "data": { + "update_interval": "Update interval (minutes)" + } + } } } } diff --git a/homeassistant/components/plaato/translations/cs.json b/homeassistant/components/plaato/translations/cs.json index 582a3e3a180..4d736d1c695 100644 --- a/homeassistant/components/plaato/translations/cs.json +++ b/homeassistant/components/plaato/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace.", "webhook_not_internet_accessible": "V\u00e1\u0161 Home Assistant mus\u00ed b\u00fdt p\u0159\u00edstupn\u00fd z internetu, aby mohl p\u0159ij\u00edmat zpr\u00e1vy webhook." }, diff --git a/homeassistant/components/plaato/translations/de.json b/homeassistant/components/plaato/translations/de.json index 5171baab654..eaf68b507f9 100644 --- a/homeassistant/components/plaato/translations/de.json +++ b/homeassistant/components/plaato/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Konto wurde bereits konfiguriert", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, diff --git a/homeassistant/components/plaato/translations/en.json b/homeassistant/components/plaato/translations/en.json index 6f25c15583c..64d41d0091e 100644 --- a/homeassistant/components/plaato/translations/en.json +++ b/homeassistant/components/plaato/translations/en.json @@ -1,16 +1,53 @@ { "config": { "abort": { + "already_configured": "Account is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible.", "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + "default": "Your Plaato {device_type} with name **{device_name}** was successfully setup!" + }, + "error": { + "invalid_webhook_device": "You have selected a device that doesn't not support sending data to a webhook. It is only available for the Airlock", + "no_api_method": "You need to add an auth token or select webhook", + "no_auth_token": "You need to add an auth token" }, "step": { + "api_method": { + "data": { + "token": "Paste Auth Token here", + "use_webhook": "Use webhook" + }, + "description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank", + "title": "Select API method" + }, "user": { + "data": { + "device_name": "Name your device", + "device_type": "Type of Plaato device" + }, "description": "Do you want to start set up?", - "title": "Set up the Plaato Webhook" + "title": "Set up the Plaato devices" + }, + "webhook": { + "description": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.", + "title": "Webhook to use" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "Update interval (minutes)" + }, + "description": "Set the update interval (minutes)", + "title": "Options for Plaato" + }, + "webhook": { + "description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n", + "title": "Options for Plaato Airlock" } } } diff --git a/homeassistant/components/plaato/translations/es.json b/homeassistant/components/plaato/translations/es.json index 0f030e56b4e..e0b6c767043 100644 --- a/homeassistant/components/plaato/translations/es.json +++ b/homeassistant/components/plaato/translations/es.json @@ -1,16 +1,53 @@ { "config": { "abort": { + "already_configured": "La cuenta ya ha sido configurada", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, "create_entry": { "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en Plaato Airlock.\n\nCompleta la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n\nEcha un vistazo a [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." }, + "error": { + "invalid_webhook_device": "Has seleccionado un dispositivo que no admite el env\u00edo de datos a un webhook. Solo est\u00e1 disponible para Airlock", + "no_api_method": "Necesitas a\u00f1adir un token de autenticaci\u00f3n o seleccionar un webhook", + "no_auth_token": "Es necesario a\u00f1adir un token de autenticaci\u00f3n" + }, "step": { + "api_method": { + "data": { + "token": "Pega el token de autenticaci\u00f3n aqu\u00ed", + "use_webhook": "Usar webhook" + }, + "description": "Para poder consultar la API se necesita un `auth_token` que puede obtenerse siguiendo [estas](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instrucciones\n\n Dispositivo seleccionado: **{device_type}** \n\nSi prefiere utilizar el m\u00e9todo de webhook incorporado (s\u00f3lo Airlock), marque la casilla siguiente y deje en blanco el Auth Token", + "title": "Selecciona el m\u00e9todo API" + }, "user": { + "data": { + "device_name": "Nombre de su dispositivo", + "device_type": "Tipo de dispositivo Plaato" + }, "description": "\u00bfEst\u00e1s seguro de que quieres configurar el Airlock de Plaato?", "title": "Configurar el webhook de Plaato" + }, + "webhook": { + "description": "Para enviar eventos a Home Assistant, deber\u00e1 configurar la funci\u00f3n de webhook en Plaato Airlock. \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n Consulte [la documentaci\u00f3n]({docs_url}) para obtener m\u00e1s detalles.", + "title": "Webhook a utilizar" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "Intervalo de actualizaci\u00f3n (minutos)" + }, + "description": "Intervalo de actualizaci\u00f3n (minutos)", + "title": "Opciones de Plaato" + }, + "webhook": { + "description": "Informaci\u00f3n de webhook: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n", + "title": "Opciones para Plaato Airlock" } } } diff --git a/homeassistant/components/plaato/translations/et.json b/homeassistant/components/plaato/translations/et.json index ec7b7e4b1a4..66a7b252a87 100644 --- a/homeassistant/components/plaato/translations/et.json +++ b/homeassistant/components/plaato/translations/et.json @@ -24,7 +24,7 @@ }, "user": { "data": { - "device_name": "Pang oma seadmele nimi", + "device_name": "Pane oma seadmele nimi", "device_type": "Plaato seadme t\u00fc\u00fcp" }, "description": "Kas alustan seadistamist?", diff --git a/homeassistant/components/plaato/translations/fr.json b/homeassistant/components/plaato/translations/fr.json index bc442a04c60..ab3c01144dd 100644 --- a/homeassistant/components/plaato/translations/fr.json +++ b/homeassistant/components/plaato/translations/fr.json @@ -1,16 +1,53 @@ { "config": { "abort": { + "already_configured": "Le compte est d\u00e9ja configur\u00e9", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "webhook_not_internet_accessible": "Votre installation de Home Assistant doit \u00eatre accessible depuis internet pour recevoir des messages webhook." }, "create_entry": { "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonction Webhook dans Plaato Airlock. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails." }, + "error": { + "invalid_webhook_device": "Vous avez s\u00e9lectionn\u00e9 un appareil qui ne prend pas en charge l'envoi de donn\u00e9es vers un webhook. Il n'est disponible que pour le sas", + "no_api_method": "Vous devez ajouter un jeton d'authentification ou s\u00e9lectionner un webhook", + "no_auth_token": "Vous devez ajouter un jeton d'authentification" + }, "step": { + "api_method": { + "data": { + "token": "Collez le jeton d'authentification ici", + "use_webhook": "Utiliser le webhook" + }, + "description": "Pour pouvoir interroger l'API, un \u00abauth_token\u00bb est n\u00e9cessaire. Il peut \u00eatre obtenu en suivant [ces] instructions (https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) \n\n Appareil s\u00e9lectionn\u00e9: ** {device_type} ** \n\n Si vous pr\u00e9f\u00e9rez utiliser la m\u00e9thode Webhook int\u00e9gr\u00e9e (Airlock uniquement), veuillez cocher la case ci-dessous et laisser le jeton d'authentification vide", + "title": "S\u00e9lectionnez la m\u00e9thode API" + }, "user": { + "data": { + "device_name": "Nommez votre appareil", + "device_type": "Type d'appareil Plaato" + }, "description": "\u00cates-vous s\u00fbr de vouloir installer le Plaato Airlock ?", "title": "Configurer le Webhook Plaato" + }, + "webhook": { + "description": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonction Webhook dans Plaato Airlock. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails.", + "title": "Webhook \u00e0 utiliser" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "Intervalle de mise \u00e0 jour (minutes)" + }, + "description": "D\u00e9finir l'intervalle de mise \u00e0 jour (minutes)", + "title": "Options pour Plaato" + }, + "webhook": { + "description": "Informations sur le webhook: \n\n - URL: ` {webhook_url} `\n - M\u00e9thode: POST \n\n", + "title": "Options pour Plaato Airlock" } } } diff --git a/homeassistant/components/plaato/translations/it.json b/homeassistant/components/plaato/translations/it.json index ad289fa758f..acd2fcfa3f4 100644 --- a/homeassistant/components/plaato/translations/it.json +++ b/homeassistant/components/plaato/translations/it.json @@ -1,16 +1,53 @@ { "config": { "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", "webhook_not_internet_accessible": "L'istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi webhook." }, "create_entry": { - "default": "Per inviare eventi a Home Assistant, dovrai impostare la funzione webhook in Plaato Airlock. \n\n Inserisci le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Metodo: POST \n\n Vedi [la documentazione]({docs_url}) per ulteriori dettagli." + "default": "Il tuo Plaato {device_type} con nome **{device_name}** \u00e8 stato configurato con successo!" + }, + "error": { + "invalid_webhook_device": "Hai selezionato un dispositivo che non supporta l'invio di dati a un webhook. \u00c8 disponibile solo per Airlock", + "no_api_method": "Devi aggiungere un token di autenticazione o selezionare webhook", + "no_auth_token": "\u00c8 necessario aggiungere un token di autorizzazione" }, "step": { + "api_method": { + "data": { + "token": "Incolla il token di autenticazione qui", + "use_webhook": "Usa webhook" + }, + "description": "Per poter interrogare l'API \u00e8 necessario un `auth_token` che pu\u00f2 essere ottenuto seguendo [queste] (https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) istruzioni \n\n Dispositivo selezionato: **{device_type}** \n\n Se preferisci utilizzare il metodo webhook integrato (solo Airlock), seleziona la casella sottostante e lascia vuoto il token di autenticazione", + "title": "Seleziona il metodo API" + }, "user": { + "data": { + "device_name": "Assegna un nome al dispositivo", + "device_type": "Tipo di dispositivo Plaato" + }, "description": "Vuoi iniziare la configurazione?", - "title": "Configura il webhook di Plaato" + "title": "Imposta i dispositivi Plaato" + }, + "webhook": { + "description": "Per inviare eventi a Home Assistant, dovrai configurare la funzione webhook in Plaato Airlock. \n\n Compila le seguenti informazioni: \n\n - URL: \"{webhook_url}\"\n - Metodo: POST \n\n Vedere [la documentazione] ({docs_url}) per ulteriori dettagli.", + "title": "Webhook da utilizzare" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "Intervallo di aggiornamento (minuti)" + }, + "description": "Imposta l'intervallo di aggiornamento (minuti)", + "title": "Opzioni per Plaato" + }, + "webhook": { + "description": "Informazioni webhook:\n\n- URL: \"{webhook_url}\"\n- Metodo: POST\n\n", + "title": "Opzioni per Plaato Airlock" } } } diff --git a/homeassistant/components/plaato/translations/ko.json b/homeassistant/components/plaato/translations/ko.json index 6eeb6a9c061..753653c88b2 100644 --- a/homeassistant/components/plaato/translations/ko.json +++ b/homeassistant/components/plaato/translations/ko.json @@ -1,12 +1,17 @@ { "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Plaato Airlock \uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4.\n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + "default": "**{device_name}** \uc758 Plaato {device_type} \uc774(\uac00) \uc131\uacf5\uc801\uc73c\ub85c \uc124\uc815\ub418\uc5c8\uc2b5\ub2c8\ub2e4!" }, "step": { "user": { - "description": "Plaato Airlock \uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", - "title": "Plaato \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Plaato \uae30\uae30 \uc124\uc815\ud558\uae30" } } } diff --git a/homeassistant/components/plaato/translations/nl.json b/homeassistant/components/plaato/translations/nl.json index 6545a659427..c0a9d1e04fb 100644 --- a/homeassistant/components/plaato/translations/nl.json +++ b/homeassistant/components/plaato/translations/nl.json @@ -1,16 +1,42 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "already_configured": "Account is al geconfigureerd", + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar de Home Assistant te sturen, moet u de webhook-functie instellen in Plaato Airlock. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n\n Zie [de documentatie] ( {docs_url} ) voor meer informatie." }, + "error": { + "no_auth_token": "U moet een verificatie token toevoegen" + }, "step": { + "api_method": { + "data": { + "token": "Plak hier de verificatie-token", + "use_webhook": "Webhook gebruiken" + }, + "title": "Selecteer API-methode" + }, "user": { + "data": { + "device_name": "Geef uw apparaat een naam", + "device_type": "Type Plaato-apparaat" + }, "description": "Weet u zeker dat u de Plaato-airlock wilt instellen?", "title": "Stel de Plaato Webhook in" } } + }, + "options": { + "step": { + "user": { + "title": "Opties voor Plaato" + }, + "webhook": { + "title": "Opties voor Plaato Airlock" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/plaato/translations/no.json b/homeassistant/components/plaato/translations/no.json index 8873399aaa4..8efbf07945f 100644 --- a/homeassistant/components/plaato/translations/no.json +++ b/homeassistant/components/plaato/translations/no.json @@ -6,7 +6,7 @@ "webhook_not_internet_accessible": "Home Assistant forekomsten din m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta webhook meldinger" }, "create_entry": { - "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Plaato Airlock. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer." + "default": "Plaato {device_type} med navnet **{device_name}** ble konfigurert!" }, "error": { "invalid_webhook_device": "Du har valgt en enhet som ikke st\u00f8tter sending av data til en webhook. Den er kun tilgjengelig for Airlock", @@ -15,6 +15,11 @@ }, "step": { "api_method": { + "data": { + "token": "Lim inn Auth Token her", + "use_webhook": "Bruk webhook" + }, + "description": "For \u00e5 kunne s\u00f8ke p\u00e5 API-en kreves det en `auth_token` som kan oppn\u00e5s ved \u00e5 f\u00f8lge [disse] (https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instruksjonene \n\n Valgt enhet: **{device_type}** \n\n Hvis du heller bruker den innebygde webhook-metoden (kun luftsperre), vennligst merk av i ruten nedenfor og la Auth Token v\u00e6re tom.", "title": "Velg API-metode" }, "user": { @@ -23,7 +28,7 @@ "device_type": "Type Platon-enhet" }, "description": "Vil du starte oppsettet?", - "title": "Sett opp Plaato Webhook" + "title": "Sett opp Plaato-enhetene" }, "webhook": { "description": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Plaato Airlock. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer.", diff --git a/homeassistant/components/plaato/translations/pl.json b/homeassistant/components/plaato/translations/pl.json index c849f574c9c..57df32c3f4e 100644 --- a/homeassistant/components/plaato/translations/pl.json +++ b/homeassistant/components/plaato/translations/pl.json @@ -46,7 +46,7 @@ "title": "Opcje dla Plaato" }, "webhook": { - "description": "Informacje o webhook: \n\n - URL: `{webhook_url}`\n - Metoda: POST \n\n", + "description": "Informacje o webhooku: \n\n - URL: `{webhook_url}`\n - Metoda: POST \n\n", "title": "Opcje dla areomierza Plaato Airlock" } } diff --git a/homeassistant/components/plaato/translations/ru.json b/homeassistant/components/plaato/translations/ru.json index 99e28ac9e04..99e1bf94e0d 100644 --- a/homeassistant/components/plaato/translations/ru.json +++ b/homeassistant/components/plaato/translations/ru.json @@ -1,15 +1,52 @@ { "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.", "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.", "webhook_not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f Webhook-\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439." }, "create_entry": { - "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Webhook \u0434\u043b\u044f Plaato Airlock.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Plaato {device_type} **{device_name}** \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "invalid_webhook_device": "\u0412\u044b \u0432\u044b\u0431\u0440\u0430\u043b\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0443 \u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 Webhook. \u042d\u0442\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0448\u043b\u044e\u0437\u0430.", + "no_api_method": "\u041d\u0443\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0438\u043b\u0438 \u0432\u044b\u0431\u0440\u0430\u0442\u044c Webhook.", + "no_auth_token": "\u041d\u0443\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { + "api_method": { + "data": { + "token": "\u0412\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u0442\u043e\u043a\u0435\u043d \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441\u044e\u0434\u0430", + "use_webhook": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c Webhook" + }, + "description": "\u0427\u0442\u043e\u0431\u044b \u0438\u043c\u0435\u0442\u044c \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u0437\u0430\u043f\u0440\u0430\u0448\u0438\u0432\u0430\u0442\u044c API, \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f `auth_token`, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043e\u0436\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c, \u0441\u043b\u0435\u0434\u0443\u044f [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) \n\n\u0412\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e: **{device_type}** \n\n\u0415\u0441\u043b\u0438 \u0412\u044b \u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0438\u0442\u0430\u0435\u0442\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0439 Webhook (\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Airlock), \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0444\u043b\u0430\u0436\u043e\u043a \u043d\u0438\u0436\u0435 \u0438 \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u0442\u043e\u043a\u0435\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043f\u0443\u0441\u0442\u044b\u043c.", + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 API" + }, "user": { + "data": { + "device_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "device_type": "\u0422\u0438\u043f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Plaato" + }, "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Plaato" + }, + "webhook": { + "description": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Webhook \u0434\u043b\u044f Plaato Airlock.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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.", + "title": "Webhook" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f (\u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445)" + }, + "description": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f (\u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445)", + "title": "Plaato" + }, + "webhook": { + "description": "Webhook:\n\n- URL: `{webhook_url}`\n- Method: POST", "title": "Plaato Airlock" } } diff --git a/homeassistant/components/plaato/translations/zh-Hant.json b/homeassistant/components/plaato/translations/zh-Hant.json index 2890c5c31c6..26d7b728771 100644 --- a/homeassistant/components/plaato/translations/zh-Hant.json +++ b/homeassistant/components/plaato/translations/zh-Hant.json @@ -10,16 +10,16 @@ }, "error": { "invalid_webhook_device": "\u6240\u9078\u64c7\u7684\u88dd\u7f6e\u4e0d\u652f\u63f4\u50b3\u9001\u8cc7\u6599\u81f3 Webhook\u3001AirLock \u50c5\u652f\u63f4\u6b64\u985e\u578b", - "no_api_method": "\u9700\u8981\u65b0\u589e\u6388\u6b0a\u5bc6\u9470\u6216\u9078\u64c7 Webhook", - "no_auth_token": "\u9700\u8981\u65b0\u589e\u6388\u6b0a\u5bc6\u9470" + "no_api_method": "\u9700\u8981\u65b0\u589e\u6388\u6b0a\u6b0a\u6756\u6216\u9078\u64c7 Webhook", + "no_auth_token": "\u9700\u8981\u65b0\u589e\u6388\u6b0a\u6b0a\u6756" }, "step": { "api_method": { "data": { - "token": "\u65bc\u6b64\u8cbc\u4e0a\u6388\u6b0a\u5bc6\u9470", + "token": "\u65bc\u6b64\u8cbc\u4e0a\u6388\u6b0a\u6b0a\u6756", "use_webhook": "\u4f7f\u7528 Webhook" }, - "description": "\u9700\u8981\u6388\u6b0a\u5bc6\u8981 `auth_token` \u65b9\u80fd\u67e5\u8a62 API\u3002\u7372\u5f97\u7684\u65b9\u6cd5\u8acb [\u53c3\u95b1](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) \u6559\u5b78\n\n\u9078\u64c7\u7684\u88dd\u7f6e\uff1a**{device_type}** \n\n\u5047\u5982\u9078\u64c7\u5167\u5efa Webhook \u65b9\u6cd5\uff08Airlock \u552f\u4e00\u652f\u63f4\uff09\uff0c\u8acb\u6aa2\u67e5\u4e0b\u65b9\u6838\u9078\u76d2\u4e26\u78ba\u5b9a\u4fdd\u6301\u6388\u6b0a\u5bc6\u9470\u6b04\u4f4d\u7a7a\u767d", + "description": "\u9700\u8981\u6388\u6b0a\u5bc6\u8981 `auth_token` \u65b9\u80fd\u67e5\u8a62 API\u3002\u7372\u5f97\u7684\u65b9\u6cd5\u8acb [\u53c3\u95b1](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) \u6559\u5b78\n\n\u9078\u64c7\u7684\u88dd\u7f6e\uff1a**{device_type}** \n\n\u5047\u5982\u9078\u64c7\u5167\u5efa Webhook \u65b9\u6cd5\uff08Airlock \u552f\u4e00\u652f\u63f4\uff09\uff0c\u8acb\u6aa2\u67e5\u4e0b\u65b9\u6838\u9078\u76d2\u4e26\u78ba\u5b9a\u4fdd\u6301\u6388\u6b0a\u6b0a\u6756\u6b04\u4f4d\u7a7a\u767d", "title": "\u9078\u64c7 API \u65b9\u5f0f" }, "user": { diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index f177412e7ec..d611c09c43e 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -230,10 +230,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } entry = await self.async_set_unique_id(server_id) - if ( - self.context[CONF_SOURCE] # pylint: disable=no-member - == config_entries.SOURCE_REAUTH - ): + if self.context[CONF_SOURCE] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry(entry, data=data) _LOGGER.debug("Updated config entry for %s", plex_server.friendly_name) await self.hass.config_entries.async_reload(entry.entry_id) @@ -243,7 +240,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Valid config created for %s", plex_server.friendly_name) - return self.async_create_entry(title=plex_server.friendly_name, data=data) + return self.async_create_entry(title=url, data=data) async def async_step_select_server(self, user_input=None): """Use selected Plex server.""" @@ -280,7 +277,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() host = f"{discovery_info['from'][0]}:{discovery_info['data']['Port']}" name = discovery_info["data"]["Name"] - self.context["title_placeholders"] = { # pylint: disable=no-member + self.context["title_placeholders"] = { "host": host, "name": name, } diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 913f405cfcd..49388bdfdb6 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.3.1", + "plexapi==4.4.0", "plexauth==0.0.6", "plexwebsocket==0.0.12" ], diff --git a/homeassistant/components/plex/models.py b/homeassistant/components/plex/models.py index 7633c5deaa8..731d5bbc7db 100644 --- a/homeassistant/components/plex/models.py +++ b/homeassistant/components/plex/models.py @@ -70,7 +70,7 @@ class PlexSession: self.media_library_title = "Live TV" else: self.media_library_title = ( - media.section().title if media.section() is not None else "" + media.section().title if media.librarySectionID is not None else "" ) if media.type == "episode": diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 1baceb78ff1..8f9d4d1cc51 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -213,21 +213,27 @@ class PlexServer: try: system_accounts = self._plex_server.systemAccounts() + shared_users = self.account.users() if self.account else [] except Unauthorized: _LOGGER.warning( "Plex account has limited permissions, shared account filtering will not be available" ) else: - self._accounts = [ - account.name for account in system_accounts if account.name - ] + self._accounts = [] + for user in shared_users: + for shared_server in user.servers: + if shared_server.machineIdentifier == self.machine_identifier: + self._accounts.append(user.title) + _LOGGER.debug("Linked accounts: %s", self.accounts) - owner_account = [ - account.name for account in system_accounts if account.accountID == 1 - ] + owner_account = next( + (account.name for account in system_accounts if account.accountID == 1), + None, + ) if owner_account: - self._owner_username = owner_account[0] + self._owner_username = owner_account + self._accounts.append(owner_account) _LOGGER.debug("Server owner found: '%s'", self._owner_username) self._version = self._plex_server.version diff --git a/homeassistant/components/plex/translations/it.json b/homeassistant/components/plex/translations/it.json index f470c1bc163..f1ec23e2736 100644 --- a/homeassistant/components/plex/translations/it.json +++ b/homeassistant/components/plex/translations/it.json @@ -4,7 +4,7 @@ "all_configured": "Tutti i server collegati sono gi\u00e0 configurati", "already_configured": "Questo server Plex \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", - "reauth_successful": "La riautenticazione ha avuto successo", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "token_request_timeout": "Timeout per l'ottenimento del token", "unknown": "Errore imprevisto" }, diff --git a/homeassistant/components/plex/translations/ko.json b/homeassistant/components/plex/translations/ko.json index 7c461fe1673..df728533467 100644 --- a/homeassistant/components/plex/translations/ko.json +++ b/homeassistant/components/plex/translations/ko.json @@ -3,9 +3,10 @@ "abort": { "all_configured": "\uc774\ubbf8 \uad6c\uc131\ub41c \ubaa8\ub4e0 \uc5f0\uacb0\ub41c \uc11c\ubc84", "already_configured": "\uc774 Plex \uc11c\ubc84\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "Plex \ub97c \uad6c\uc131 \uc911\uc785\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4", "token_request_timeout": "\ud1a0\ud070 \ud68d\ub4dd \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc774\uc720\ub85c \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "faulty_credentials": "\uc778\uc99d\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. \ud1a0\ud070\uc744 \ud655\uc778\ud574\uc8fc\uc138\uc694", @@ -20,9 +21,9 @@ "data": { "host": "\ud638\uc2a4\ud2b8", "port": "\ud3ec\ud2b8", - "ssl": "SSL \uc0ac\uc6a9", + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", "token": "\ud1a0\ud070 (\uc120\ud0dd \uc0ac\ud56d)", - "verify_ssl": "SSL \uc778\uc99d\uc11c \uac80\uc99d" + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "title": "Plex \uc9c1\uc811 \uad6c\uc131\ud558\uae30" }, diff --git a/homeassistant/components/plex/translations/nl.json b/homeassistant/components/plex/translations/nl.json index 00c2b30c490..6c89b0b8d5f 100644 --- a/homeassistant/components/plex/translations/nl.json +++ b/homeassistant/components/plex/translations/nl.json @@ -4,6 +4,7 @@ "all_configured": "Alle gekoppelde servers zijn al geconfigureerd", "already_configured": "Deze Plex-server is al geconfigureerd", "already_in_progress": "Plex wordt geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol", "token_request_timeout": "Time-out verkrijgen van token", "unknown": "Mislukt om onbekende reden" }, diff --git a/homeassistant/components/plex/translations/zh-Hant.json b/homeassistant/components/plex/translations/zh-Hant.json index 137b953a145..7f19fa0d035 100644 --- a/homeassistant/components/plex/translations/zh-Hant.json +++ b/homeassistant/components/plex/translations/zh-Hant.json @@ -5,12 +5,12 @@ "already_configured": "Plex \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", - "token_request_timeout": "\u53d6\u5f97\u5bc6\u9470\u903e\u6642", + "token_request_timeout": "\u53d6\u5f97\u6b0a\u6756\u903e\u6642", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { - "faulty_credentials": "\u9a57\u8b49\u5931\u6557\u3001\u78ba\u8a8d\u5bc6\u9470", - "host_or_token": "\u5fc5\u9808\u81f3\u5c11\u63d0\u4f9b\u4e3b\u6a5f\u7aef\u6216\u5bc6\u9470", + "faulty_credentials": "\u9a57\u8b49\u5931\u6557\u3001\u78ba\u8a8d\u6b0a\u6756", + "host_or_token": "\u5fc5\u9808\u81f3\u5c11\u63d0\u4f9b\u4e3b\u6a5f\u7aef\u6216\u6b0a\u6756", "no_servers": "Plex \u5e33\u865f\u672a\u7d81\u5b9a\u4efb\u4f55\u4f3a\u670d\u5668", "not_found": "\u627e\u4e0d\u5230 Plex \u4f3a\u670d\u5668", "ssl_error": "SSL \u8a8d\u8b49\u554f\u984c" @@ -22,7 +22,7 @@ "host": "\u4e3b\u6a5f\u7aef", "port": "\u901a\u8a0a\u57e0", "ssl": "\u4f7f\u7528 SSL \u8a8d\u8b49", - "token": "\u5bc6\u9470\uff08\u9078\u9805\uff09", + "token": "\u6b0a\u6756\uff08\u9078\u9805\uff09", "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" }, "title": "Plex \u624b\u52d5\u8a2d\u5b9a" diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index e0d22627737..e17c85a7978 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -19,12 +19,7 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType -from .const import ( # pylint:disable=unused-import - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - ZEROCONF_MAP, -) +from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, ZEROCONF_MAP _LOGGER = logging.getLogger(__name__) @@ -102,7 +97,6 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _version = _properties.get("version", "n/a") _name = f"{ZEROCONF_MAP.get(_product, _product)} v{_version}" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info.get(CONF_PORT, DEFAULT_PORT), @@ -152,7 +146,6 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - # PLACEHOLDER USB vs Gateway Logic return await self.async_step_user_gateway() diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index c6ef43af602..fb8911d6fc7 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -22,7 +22,6 @@ DEFAULT_SCAN_INTERVAL = {"power": 10, "stretch": 60, "thermostat": 60} DEFAULT_TIMEOUT = 60 # Configuration directives -CONF_BASE = "base" CONF_GAS = "gas" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py index a0bf23986bd..c14395319d4 100644 --- a/homeassistant/components/plugwise/gateway.py +++ b/homeassistant/components/plugwise/gateway.py @@ -203,7 +203,6 @@ class SmileGateway(CoordinatorEntity): @property def device_info(self) -> Dict[str, any]: """Return the device information.""" - device_information = { "identifiers": {(DOMAIN, self._dev_id)}, "name": self._entity_name, diff --git a/homeassistant/components/plugwise/translations/fr.json b/homeassistant/components/plugwise/translations/fr.json index 2fdc3502571..f89c8509136 100644 --- a/homeassistant/components/plugwise/translations/fr.json +++ b/homeassistant/components/plugwise/translations/fr.json @@ -21,7 +21,8 @@ "data": { "host": "Adresse IP", "password": "ID Smile", - "port": "Port" + "port": "Port", + "username": "Nom d'utilisateur de sourire" }, "description": "Veuillez saisir :", "title": "Se connecter \u00e0 Smile" diff --git a/homeassistant/components/plugwise/translations/ko.json b/homeassistant/components/plugwise/translations/ko.json index 4af12098b7f..7af503f0a66 100644 --- a/homeassistant/components/plugwise/translations/ko.json +++ b/homeassistant/components/plugwise/translations/ko.json @@ -1,18 +1,24 @@ { "config": { "abort": { - "already_configured": "\uc774 Smile \uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. 8\uc790\uc758 Smile ID \ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Smile: {name}", "step": { "user": { - "description": "\uc138\ubd80 \uc815\ubcf4", - "title": "Smile \uc5d0 \uc5f0\uacb0\ud558\uae30" + "description": "\uc81c\ud488:", + "title": "Plugwise \uc720\ud615" + }, + "user_gateway": { + "data": { + "host": "IP \uc8fc\uc18c", + "port": "\ud3ec\ud2b8" + } } } }, diff --git a/homeassistant/components/plugwise/translations/ru.json b/homeassistant/components/plugwise/translations/ru.json index 8a59d492e66..9df460e8919 100644 --- a/homeassistant/components/plugwise/translations/ru.json +++ b/homeassistant/components/plugwise/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "Smile: {name}", diff --git a/homeassistant/components/plum_lightpad/translations/ko.json b/homeassistant/components/plum_lightpad/translations/ko.json index 008177f1cec..be71285cbb7 100644 --- a/homeassistant/components/plum_lightpad/translations/ko.json +++ b/homeassistant/components/plum_lightpad/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/plum_lightpad/translations/nl.json b/homeassistant/components/plum_lightpad/translations/nl.json index 7f0f85b7326..8410cabbbb9 100644 --- a/homeassistant/components/plum_lightpad/translations/nl.json +++ b/homeassistant/components/plum_lightpad/translations/nl.json @@ -9,7 +9,8 @@ "step": { "user": { "data": { - "password": "Wachtwoord" + "password": "Wachtwoord", + "username": "E-mail" } } } diff --git a/homeassistant/components/point/translations/en.json b/homeassistant/components/point/translations/en.json index 50e9e4f3ce0..685a16cbbf5 100644 --- a/homeassistant/components/point/translations/en.json +++ b/homeassistant/components/point/translations/en.json @@ -6,7 +6,7 @@ "authorize_url_timeout": "Timeout generating authorize URL.", "external_setup": "Point successfully configured from another flow.", "no_flows": "The component is not configured. Please follow the documentation.", - "unknown_authorize_url_generation": "Unknown error generating an authorize url." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL." }, "create_entry": { "default": "Successfully authenticated" diff --git a/homeassistant/components/point/translations/ko.json b/homeassistant/components/point/translations/ko.json index 6ea58e95834..f4ca6002036 100644 --- a/homeassistant/components/point/translations/ko.json +++ b/homeassistant/components/point/translations/ko.json @@ -2,10 +2,11 @@ "config": { "abort": { "already_setup": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", - "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "external_setup": "Point \uac00 \ub2e4\ub978 \uad6c\uc131 \ub2e8\uacc4\uc5d0\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "no_flows": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + "no_flows": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "unknown_authorize_url_generation": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." }, "create_entry": { "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/point/translations/nl.json b/homeassistant/components/point/translations/nl.json index a257ba3e111..94763a412a0 100644 --- a/homeassistant/components/point/translations/nl.json +++ b/homeassistant/components/point/translations/nl.json @@ -5,7 +5,8 @@ "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "external_setup": "Punt succesvol geconfigureerd vanuit een andere stroom.", - "no_flows": "U moet Point configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)." + "no_flows": "U moet Point configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/).", + "unknown_authorize_url_generation": "Onbekende fout bij het genereren van een autorisatie-URL." }, "create_entry": { "default": "Succesvol geverifieerd met Minut voor uw Point appara(a)t(en)" diff --git a/homeassistant/components/point/translations/no.json b/homeassistant/components/point/translations/no.json index d0d0b9114fb..a72a8083f6f 100644 --- a/homeassistant/components/point/translations/no.json +++ b/homeassistant/components/point/translations/no.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_setup": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", - "authorize_url_fail": "Ukjent feil ved generering av godkjenningsadresse", + "authorize_url_fail": "Ukjent feil under generering av en autoriserings-URL.", "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "external_setup": "Punktet er konfigurert fra en annen flyt.", "no_flows": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", - "unknown_authorize_url_generation": "Ukjent feil ved generering av godkjenningsadresse" + "unknown_authorize_url_generation": "Ukjent feil under generering av en autoriserings-URL." }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/point/translations/zh-Hant.json b/homeassistant/components/point/translations/zh-Hant.json index 710d363f771..2bb1a8fc239 100644 --- a/homeassistant/components/point/translations/zh-Hant.json +++ b/homeassistant/components/point/translations/zh-Hant.json @@ -13,7 +13,7 @@ }, "error": { "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002", - "no_token": "\u5b58\u53d6\u5bc6\u9470\u7121\u6548" + "no_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548" }, "step": { "auth": { diff --git a/homeassistant/components/poolsense/translations/ko.json b/homeassistant/components/poolsense/translations/ko.json index 42a6654592f..ec8c7dfc90f 100644 --- a/homeassistant/components/poolsense/translations/ko.json +++ b/homeassistant/components/poolsense/translations/ko.json @@ -12,7 +12,7 @@ "email": "\uc774\uba54\uc77c", "password": "\ube44\ubc00\ubc88\ud638" }, - "description": "[%key:common::config_flow::description%]", + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "PoolSense" } } diff --git a/homeassistant/components/poolsense/translations/nl.json b/homeassistant/components/poolsense/translations/nl.json index 7482a0bbe7c..38ef34d5afc 100644 --- a/homeassistant/components/poolsense/translations/nl.json +++ b/homeassistant/components/poolsense/translations/nl.json @@ -9,8 +9,10 @@ "step": { "user": { "data": { + "email": "E-mail", "password": "Wachtwoord" - } + }, + "description": "Wil je beginnen met instellen?" } } } diff --git a/homeassistant/components/poolsense/translations/ru.json b/homeassistant/components/poolsense/translations/ru.json index 3687b75a6f7..09c94368cda 100644 --- a/homeassistant/components/poolsense/translations/ru.json +++ b/homeassistant/components/poolsense/translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index b649b160085..eb804df3420 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -65,7 +65,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") self.ip_address = dhcp_discovery[IP_ADDRESS] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {CONF_IP_ADDRESS: self.ip_address} return await self.async_step_user() diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index c576d931756..5deacd6a8f9 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Connect to the powerwall", - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/powerwall/translations/ca.json b/homeassistant/components/powerwall/translations/ca.json index 4b176fff686..8016cd12371 100644 --- a/homeassistant/components/powerwall/translations/ca.json +++ b/homeassistant/components/powerwall/translations/ca.json @@ -1,10 +1,12 @@ { "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", "unknown": "Error inesperat", "wrong_version": "El teu Powerwall utilitza una versi\u00f3 de programari no compatible. L'hauries d'actualitzar o informar d'aquest problema perqu\u00e8 sigui solucionat." }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "Adre\u00e7a IP" + "ip_address": "Adre\u00e7a IP", + "password": "Contrasenya" }, + "description": "La contrasenya normalment s\u00f3n els darrers cinc car\u00e0cters del n\u00famero de s\u00e8rie de la pasarel\u00b7la (backup gateway) i es pot trobar a l'aplicaci\u00f3 de Tesla. Tamb\u00e9 s\u00f3n els darrers 5 car\u00e0cters de la contrasenya que es troba a l'interior de la tapa de la pasarel\u00b7la vers\u00f3 2 (backup gateway 2).", "title": "Connexi\u00f3 amb el Powerwall" } } diff --git a/homeassistant/components/powerwall/translations/cs.json b/homeassistant/components/powerwall/translations/cs.json index 698934ad10e..d6e5cd5904b 100644 --- a/homeassistant/components/powerwall/translations/cs.json +++ b/homeassistant/components/powerwall/translations/cs.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba", "wrong_version": "Powerwall pou\u017e\u00edv\u00e1 verzi softwaru, kter\u00e1 nen\u00ed podporov\u00e1na. Zva\u017ete upgrade nebo nahlaste probl\u00e9m, aby mohl b\u00fdt vy\u0159e\u0161en." }, @@ -12,7 +14,8 @@ "step": { "user": { "data": { - "ip_address": "IP adresa" + "ip_address": "IP adresa", + "password": "Heslo" }, "title": "P\u0159ipojen\u00ed k powerwall" } diff --git a/homeassistant/components/powerwall/translations/de.json b/homeassistant/components/powerwall/translations/de.json index c30286d8744..0ccd42c812b 100644 --- a/homeassistant/components/powerwall/translations/de.json +++ b/homeassistant/components/powerwall/translations/de.json @@ -1,17 +1,20 @@ { "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", "unknown": "Unerwarteter Fehler" }, "flow_title": "Tesla Powerwall ({ip_address})", "step": { "user": { "data": { - "ip_address": "IP-Adresse" + "ip_address": "IP-Adresse", + "password": "Passwort" }, "title": "Stellen Sie eine Verbindung zur Powerwall her" } diff --git a/homeassistant/components/powerwall/translations/en.json b/homeassistant/components/powerwall/translations/en.json index 4ebe1e9d5ef..06fc09804d9 100644 --- a/homeassistant/components/powerwall/translations/en.json +++ b/homeassistant/components/powerwall/translations/en.json @@ -17,9 +17,9 @@ "ip_address": "IP Address", "password": "Password" }, - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", "title": "Connect to the powerwall" } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/powerwall/translations/es.json b/homeassistant/components/powerwall/translations/es.json index 373bf29f8ba..81e3edab387 100644 --- a/homeassistant/components/powerwall/translations/es.json +++ b/homeassistant/components/powerwall/translations/es.json @@ -14,6 +14,7 @@ "data": { "ip_address": "Direcci\u00f3n IP" }, + "description": "La contrase\u00f1a suele ser los \u00faltimos 5 caracteres del n\u00famero de serie del Backup Gateway y se puede encontrar en la aplicaci\u00f3n Telsa; o los \u00faltimos 5 caracteres de la contrase\u00f1a que se encuentran dentro de la puerta del Backup Gateway 2.", "title": "Conectarse al powerwall" } } diff --git a/homeassistant/components/powerwall/translations/et.json b/homeassistant/components/powerwall/translations/et.json index b10dca9b08b..8811b870316 100644 --- a/homeassistant/components/powerwall/translations/et.json +++ b/homeassistant/components/powerwall/translations/et.json @@ -1,19 +1,23 @@ { "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": "\u00dchenduse loomine nurjus. Proovi uuesti", + "invalid_auth": "Vigane autentimine", "unknown": "Ootamatu t\u00f5rge", - "wrong_version": "Teie Powerwall kasutab tarkvaraversiooni, mida ei toetata. Kaaluge tarkvara uuendamist v\u00f5i probleemist teavitamist, et see saaks lahendatud." + "wrong_version": "Powerwall kasutab tarkvaraversiooni, mida ei toetata. Kaaluge tarkvara uuendamist v\u00f5i probleemist teavitamist, et see saaks lahendatud." }, "flow_title": "Tesla Powerwall ( {ip_address} )", "step": { "user": { "data": { - "ip_address": "IP aadress" + "ip_address": "IP aadress", + "password": "Salas\u00f5na" }, + "description": "Parool on tavaliselt Backup Gateway seerianumbri viimased 5 t\u00e4hem\u00e4rki ja selle leiad Tesla rakendusest v\u00f5i Backup Gateway 2 luugilt leitud parooli viimased 5 m\u00e4rki.", "title": "Powerwalliga \u00fchendamine" } } diff --git a/homeassistant/components/powerwall/translations/fr.json b/homeassistant/components/powerwall/translations/fr.json index 3ddc6634557..3bfd70cd44c 100644 --- a/homeassistant/components/powerwall/translations/fr.json +++ b/homeassistant/components/powerwall/translations/fr.json @@ -1,18 +1,23 @@ { "config": { "abort": { - "already_configured": "Le Powerwall est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le Powerwall est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue", "wrong_version": "Votre Powerwall utilise une version logicielle qui n'est pas prise en charge. Veuillez envisager de mettre \u00e0 niveau ou de signaler ce probl\u00e8me afin qu'il puisse \u00eatre r\u00e9solu." }, + "flow_title": "Tesla Powerwall ( {ip_address} )", "step": { "user": { "data": { - "ip_address": "Adresse IP" + "ip_address": "Adresse IP", + "password": "Mot de passe" }, + "description": "Le mot de passe est g\u00e9n\u00e9ralement les 5 derniers caract\u00e8res du num\u00e9ro de s\u00e9rie de Backup Gateway et peut \u00eatre trouv\u00e9 dans l\u2019application Tesla ou les 5 derniers caract\u00e8res du mot de passe trouv\u00e9 \u00e0 l\u2019int\u00e9rieur de la porte pour la passerelle de Backup Gateway 2.", "title": "Connectez-vous au Powerwall" } } diff --git a/homeassistant/components/powerwall/translations/it.json b/homeassistant/components/powerwall/translations/it.json index 376168f8616..48cd7c04743 100644 --- a/homeassistant/components/powerwall/translations/it.json +++ b/homeassistant/components/powerwall/translations/it.json @@ -1,10 +1,12 @@ { "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", "unknown": "Errore imprevisto", "wrong_version": "Il tuo powerwall utilizza una versione del software non supportata. Si prega di considerare l'aggiornamento o la segnalazione di questo problema in modo che possa essere risolto." }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "Indirizzo IP" + "ip_address": "Indirizzo IP", + "password": "Password" }, + "description": "La password di solito \u00e8 costituita dagli ultimi 5 caratteri del numero di serie per il Backup Gateway e pu\u00f2 essere trovata nell'app Tesla; oppure dagli ultimi 5 caratteri della password trovata all'interno della porta per il Backup Gateway 2.", "title": "Connessione al Powerwall" } } diff --git a/homeassistant/components/powerwall/translations/ko.json b/homeassistant/components/powerwall/translations/ko.json index 9ba7004899f..11c638fa9bd 100644 --- a/homeassistant/components/powerwall/translations/ko.json +++ b/homeassistant/components/powerwall/translations/ko.json @@ -1,17 +1,20 @@ { "config": { "abort": { - "already_configured": "powerwall \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", "wrong_version": "Powerwall \uc774 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ubc84\uc804\uc758 \uc18c\ud504\ud2b8\uc6e8\uc5b4\ub97c \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4. \uc774 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\ub824\uba74 \uc5c5\uadf8\ub808\uc774\ub4dc\ud558\uac70\ub098 \uc774 \ub0b4\uc6a9\uc744 \uc54c\ub824\uc8fc\uc138\uc694." }, "step": { "user": { "data": { - "ip_address": "IP \uc8fc\uc18c" + "ip_address": "IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638" }, "title": "powerwall \uc5d0 \uc5f0\uacb0\ud558\uae30" } diff --git a/homeassistant/components/powerwall/translations/nl.json b/homeassistant/components/powerwall/translations/nl.json index f77cc864813..c4ae2616f46 100644 --- a/homeassistant/components/powerwall/translations/nl.json +++ b/homeassistant/components/powerwall/translations/nl.json @@ -1,17 +1,21 @@ { "config": { "abort": { - "already_configured": "De powerwall is al geconfigureerd" + "already_configured": "De powerwall is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout", "wrong_version": "Uw powerwall gebruikt een softwareversie die niet wordt ondersteund. Overweeg om dit probleem te upgraden of te melden, zodat het kan worden opgelost." }, + "flow_title": "Tesla Powerwall ({ip_adres})", "step": { "user": { "data": { - "ip_address": "IP-adres" + "ip_address": "IP-adres", + "password": "Wachtwoord" }, "title": "Maak verbinding met de powerwall" } diff --git a/homeassistant/components/powerwall/translations/no.json b/homeassistant/components/powerwall/translations/no.json index cdc04a006ad..00b77e14566 100644 --- a/homeassistant/components/powerwall/translations/no.json +++ b/homeassistant/components/powerwall/translations/no.json @@ -1,10 +1,12 @@ { "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", "unknown": "Uventet feil", "wrong_version": "Powerwall bruker en programvareversjon som ikke st\u00f8ttes. Vennligst vurder \u00e5 oppgradere eller rapportere dette problemet, s\u00e5 det kan l\u00f8ses." }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "IP adresse" + "ip_address": "IP adresse", + "password": "Passord" }, + "description": "Passordet er vanligvis de siste 5 tegnene i serienummeret for Backup Gateway, og kan bli funnet i Tesla-appen eller de siste 5 tegnene i passordet som er funnet inne i d\u00f8ren til Backup Gateway 2.", "title": "Koble til powerwall" } } diff --git a/homeassistant/components/powerwall/translations/pl.json b/homeassistant/components/powerwall/translations/pl.json index dfd4fa21a37..272f28df3b9 100644 --- a/homeassistant/components/powerwall/translations/pl.json +++ b/homeassistant/components/powerwall/translations/pl.json @@ -1,10 +1,12 @@ { "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", "unknown": "Nieoczekiwany b\u0142\u0105d", "wrong_version": "Powerwall u\u017cywa wersji oprogramowania, kt\u00f3ra nie jest obs\u0142ugiwana. Rozwa\u017c uaktualnienie lub zg\u0142oszenie tego problemu, aby mo\u017cna go by\u0142o rozwi\u0105za\u0107." }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "Adres IP" + "ip_address": "Adres IP", + "password": "Has\u0142o" }, + "description": "Has\u0142o to zazwyczaj 5 ostatnich znak\u00f3w numeru seryjnego Backup Gateway i mo\u017cna je znale\u017a\u0107 w aplikacji Tesla; lub ostatnie 5 znak\u00f3w has\u0142a na wewn\u0119trznej stronie drzwiczek Backup Gateway 2.", "title": "Po\u0142\u0105czenie z Powerwall" } } diff --git a/homeassistant/components/powerwall/translations/ru.json b/homeassistant/components/powerwall/translations/ru.json index faabf2d0ede..f79b62c2c78 100644 --- a/homeassistant/components/powerwall/translations/ru.json +++ b/homeassistant/components/powerwall/translations/ru.json @@ -1,10 +1,12 @@ { "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.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", "wrong_version": "\u0412\u0430\u0448 powerwall \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0432\u0435\u0440\u0441\u0438\u044e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0433\u043e \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u0430\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0440\u0430\u0441\u0441\u043c\u043e\u0442\u0440\u0438\u0442\u0435 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0438\u043b\u0438 \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u0435 \u043e\u0431 \u044d\u0442\u043e\u0439 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0435, \u0447\u0442\u043e\u0431\u044b \u0435\u0435 \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u0440\u0435\u0448\u0438\u0442\u044c." }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441" + "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" }, + "description": "\u041f\u0430\u0440\u043e\u043b\u044c \u043e\u0431\u044b\u0447\u043d\u043e \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u0441\u043e\u0431\u043e\u0439 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0435 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432 \u0441\u0435\u0440\u0438\u0439\u043d\u043e\u0433\u043e \u043d\u043e\u043c\u0435\u0440\u0430 \u0434\u043b\u044f Backup Gateway, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0439\u0442\u0438 \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 Telsa; \u0438\u043b\u0438 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0435 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432 \u043f\u0430\u0440\u043e\u043b\u044f, \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u043d\u0443\u0442\u0440\u0438 Backup Gateway 2.", "title": "Tesla Powerwall" } } diff --git a/homeassistant/components/powerwall/translations/zh-Hant.json b/homeassistant/components/powerwall/translations/zh-Hant.json index ec0d2e278b6..44e79e935cd 100644 --- a/homeassistant/components/powerwall/translations/zh-Hant.json +++ b/homeassistant/components/powerwall/translations/zh-Hant.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\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", "wrong_version": "\u4e0d\u652f\u63f4\u60a8\u6240\u4f7f\u7528\u7684 Powerwall \u7248\u672c\u3002\u8acb\u8003\u616e\u9032\u884c\u5347\u7d1a\u6216\u56de\u5831\u6b64\u554f\u984c\u3001\u4ee5\u671f\u554f\u984c\u53ef\u4ee5\u7372\u5f97\u89e3\u6c7a\u3002" }, @@ -12,8 +14,10 @@ "step": { "user": { "data": { - "ip_address": "IP \u4f4d\u5740" + "ip_address": "IP \u4f4d\u5740", + "password": "\u5bc6\u78bc" }, + "description": "\u5bc6\u78bc\u901a\u5e38\u70ba\u81f3\u5c11\u5099\u4efd\u9598\u9053\u5668\u5e8f\u865f\u7684\u6700\u5f8c\u4e94\u78bc\uff0c\u4e26\u4e14\u80fd\u5920\u65bc Telsa App \u4e2d\n\u627e\u5230\u3002\u6216\u8005\u70ba\u5099\u4efd\u9598\u9053\u5668 2 \u9580\u5167\u5074\u627e\u5230\u7684\u5bc6\u78bc\u6700\u5f8c\u4e94\u78bc\u3002", "title": "\u9023\u7dda\u81f3 Powerwall" } } diff --git a/homeassistant/components/profiler/translations/ko.json b/homeassistant/components/profiler/translations/ko.json new file mode 100644 index 00000000000..0c38052b826 --- /dev/null +++ b/homeassistant/components/profiler/translations/ko.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "step": { + "user": { + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/profiler/translations/nl.json b/homeassistant/components/profiler/translations/nl.json index 703ac8614c4..8690611b1c9 100644 --- a/homeassistant/components/profiler/translations/nl.json +++ b/homeassistant/components/profiler/translations/nl.json @@ -2,6 +2,11 @@ "config": { "abort": { "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + }, + "step": { + "user": { + "description": "Wil je beginnen met instellen?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/progettihwsw/translations/ko.json b/homeassistant/components/progettihwsw/translations/ko.json index 02fca814811..ab21d8427bd 100644 --- a/homeassistant/components/progettihwsw/translations/ko.json +++ b/homeassistant/components/progettihwsw/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "\uc7a5\uce58\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "relay_modes": { diff --git a/homeassistant/components/progettihwsw/translations/nl.json b/homeassistant/components/progettihwsw/translations/nl.json index ba10aee5ea2..7810a8018a4 100644 --- a/homeassistant/components/progettihwsw/translations/nl.json +++ b/homeassistant/components/progettihwsw/translations/nl.json @@ -9,6 +9,24 @@ }, "step": { "relay_modes": { + "data": { + "relay_1": "Relais 1", + "relay_10": "Relais 10", + "relay_11": "Relais 11", + "relay_12": "Relais 12", + "relay_13": "Relais 13", + "relay_14": "Relais 14", + "relay_15": "Relais 15", + "relay_16": "Relais 16", + "relay_2": "Relais 2", + "relay_3": "Relais 3", + "relay_4": "Relais 4", + "relay_5": "Relais 5", + "relay_6": "Relais 6", + "relay_7": "Relais 7", + "relay_8": "Relais 8", + "relay_9": "Relais 9" + }, "title": "Stel relais in" }, "user": { diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 014766b532e..1151c2ec332 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -1,13 +1,9 @@ """Binary sensor to read Proxmox VE data.""" -import logging - from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import COORDINATOR, DOMAIN, ProxmoxEntity -_LOGGER = logging.getLogger(__name__) - async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up binary sensors.""" diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 65d8d21fc0c..c1a01004fe9 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -2,6 +2,6 @@ "domain": "proxy", "name": "Camera Proxy", "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==8.1.0"], + "requirements": ["pillow==8.1.1"], "codeowners": [] } diff --git a/homeassistant/components/ps4/translations/ko.json b/homeassistant/components/ps4/translations/ko.json index 0e62a64d1c6..76762a0bec6 100644 --- a/homeassistant/components/ps4/translations/ko.json +++ b/homeassistant/components/ps4/translations/ko.json @@ -1,15 +1,17 @@ { "config": { "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "credential_error": "\uc790\uaca9 \uc99d\uba85\uc744 \uac00\uc838\uc624\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", - "no_devices_found": "PlayStation 4 \uae30\uae30\ub97c \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "port_987_bind_error": "\ud3ec\ud2b8 987 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.", "port_997_bind_error": "\ud3ec\ud2b8 997 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "credential_timeout": "\uc790\uaca9 \uc99d\uba85 \uc11c\ube44\uc2a4 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778\uc744 \ud074\ub9ad\ud558\uc5ec \ub2e4\uc2dc \uc2dc\uc791\ud574\uc8fc\uc138\uc694.", - "login_failed": "PlayStation 4 \uc640 \ud398\uc5b4\ub9c1\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. PIN \uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", - "no_ipaddress": "\uad6c\uc131\ud558\uace0\uc790 \ud558\ub294 PlayStation 4 \uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." + "login_failed": "PlayStation 4 \uc640 \ud398\uc5b4\ub9c1\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. PIN \ucf54\ub4dc\uac00 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "no_ipaddress": "\uad6c\uc131\ud560 PlayStation 4\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." }, "step": { "creds": { @@ -18,12 +20,12 @@ }, "link": { "data": { - "code": "PIN", + "code": "PIN \ucf54\ub4dc", "ip_address": "IP \uc8fc\uc18c", "name": "\uc774\ub984", "region": "\uc9c0\uc5ed" }, - "description": "PlayStation 4 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. 'PIN' \uc744 \ud655\uc778\ud558\ub824\uba74, PlayStation 4 \ucf58\uc194\uc5d0\uc11c '\uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud55c \ub4a4 '\ubaa8\ubc14\uc77c \uc571 \uc811\uc18d \uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec '\uae30\uae30 \ub4f1\ub85d\ud558\uae30' \ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ud654\uba74\uc5d0 \ud45c\uc2dc\ub41c 8\uc790\ub9ac \uc22b\uc790\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "description": "PlayStation 4 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. PIN \ucf54\ub4dc\ub97c \ud655\uc778\ud558\ub824\uba74, PlayStation 4 \ucf58\uc194\uc5d0\uc11c '\uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud55c \ub4a4 '\ubaa8\ubc14\uc77c \uc571 \uc811\uc18d \uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec '\uae30\uae30 \ub4f1\ub85d\ud558\uae30' \ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ud654\uba74\uc5d0 \ud45c\uc2dc\ub41c PIN \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.", "title": "PlayStation 4" }, "mode": { @@ -31,7 +33,7 @@ "ip_address": "IP \uc8fc\uc18c (\uc790\ub3d9 \uac80\uc0c9\uc744 \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0 \ube44\uc6cc\ub450\uc138\uc694)", "mode": "\uad6c\uc131 \ubaa8\ub4dc" }, - "description": "\uad6c\uc131 \ubaa8\ub4dc\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \uc790\ub3d9 \uac80\uc0c9\uc744 \uc120\ud0dd\ud558\uba74 \uae30\uae30\uac00 \uc790\ub3d9\uc73c\ub85c \uac80\uc0c9\ub418\ubbc0\ub85c IP \uc8fc\uc18c \ud544\ub4dc\ub294 \ube44\uc6cc\ub450\uc154\ub3c4 \ub429\ub2c8\ub2e4.", + "description": "\uad6c\uc131\ud560 \ubaa8\ub4dc\ub97c \uc120\ud0dd\ud569\ub2c8\ub2e4. \uc790\ub3d9 \uac80\uc0c9\uc744 \uc120\ud0dd\ud558\uba74 \uae30\uae30\uac00 \uc790\ub3d9\uc73c\ub85c \uac80\uc0c9\ub418\ubbc0\ub85c IP \uc8fc\uc18c \ud544\ub4dc\ub294 \ube44\uc6cc\ub450\uc154\ub3c4 \ub429\ub2c8\ub2e4.", "title": "PlayStation 4" } } diff --git a/homeassistant/components/ps4/translations/nl.json b/homeassistant/components/ps4/translations/nl.json index d86240b2c0a..326917e4960 100644 --- a/homeassistant/components/ps4/translations/nl.json +++ b/homeassistant/components/ps4/translations/nl.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Kon niet binden aan poort 997. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor aanvullende informatie." }, "error": { + "cannot_connect": "Kan geen verbinding maken", "credential_timeout": "Time-out van inlog service. Druk op Submit om opnieuw te starten.", "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.", "no_ipaddress": "Voer het IP-adres in van de PlayStation 4 die je wilt configureren." diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/ko.json b/homeassistant/components/pvpc_hourly_pricing/translations/ko.json index 35ac17a8bb8..f1f225ae525 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/ko.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \ud574\ub2f9 \uc694\uae08\uc81c \uc13c\uc11c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index 8efb1a32705..f608f6e12ae 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -167,7 +167,7 @@ class QldBushfireLocationEvent(GeolocationEvent): """Remove this entity.""" self._remove_signal_delete() self._remove_signal_update() - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index b16eace14fd..5867d0d6b51 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -2,6 +2,6 @@ "domain": "qrcode", "name": "QR Code", "documentation": "https://www.home-assistant.io/integrations/qrcode", - "requirements": ["pillow==8.1.0", "pyzbar==0.1.7"], + "requirements": ["pillow==8.1.1", "pyzbar==0.1.7"], "codeowners": [] } diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 8009d79b224..44a17acaecf 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -67,7 +67,6 @@ from .webhooks import ( _LOGGER = logging.getLogger(__name__) ATTR_DURATION = "duration" -ATTR_ID = "id" ATTR_PERCENT = "percent" ATTR_SCHEDULE_SUMMARY = "Summary" ATTR_SCHEDULE_ENABLED = "Enabled" diff --git a/homeassistant/components/rachio/translations/ko.json b/homeassistant/components/rachio/translations/ko.json index 2f5724c7af1..298ef476745 100644 --- a/homeassistant/components/rachio/translations/ko.json +++ b/homeassistant/components/rachio/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, @@ -13,7 +13,7 @@ "data": { "api_key": "API \ud0a4" }, - "description": "https://app.rach.io/ \uc758 API \ud0a4\uac00 \ud544\uc694\ud569\ub2c8\ub2e4. \uacc4\uc815 \uc124\uc815\uc744 \uc120\ud0dd\ud55c \ub2e4\uc74c 'GET API KEY ' \ub97c \ud074\ub9ad\ud574\uc8fc\uc138\uc694.", + "description": "https://app.rach.io/ \uc758 API \ud0a4\uac00 \ud544\uc694\ud569\ub2c8\ub2e4. Settings \ub85c \uc774\ub3d9\ud55c \ub2e4\uc74c 'GET API KEY ' \ub97c \ud074\ub9ad\ud574\uc8fc\uc138\uc694.", "title": "Rachio \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uae30" } } @@ -22,7 +22,7 @@ "step": { "init": { "data": { - "manual_run_mins": "\uc2a4\uc704\uce58\uac00 \ud65c\uc131\ud654\ub41c \uacbd\uc6b0 \uc2a4\ud14c\uc774\uc158\uc744 \ucf1c\ub294 \uc2dc\uac04(\ubd84) \uc785\ub2c8\ub2e4." + "manual_run_mins": "\uad6c\uc5ed \uc2a4\uc704\uce58\ub97c \ud65c\uc131\ud654\ud560 \ub54c \uc2e4\ud589\ud560 \uc2dc\uac04(\ubd84)" } } } diff --git a/homeassistant/components/rachio/translations/ru.json b/homeassistant/components/rachio/translations/ru.json index 53cd98387fa..52248b8d686 100644 --- a/homeassistant/components/rachio/translations/ru.json +++ b/homeassistant/components/rachio/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/rachio/translations/sv.json b/homeassistant/components/rachio/translations/sv.json index c2da7a1c01d..4932b17ebfa 100644 --- a/homeassistant/components/rachio/translations/sv.json +++ b/homeassistant/components/rachio/translations/sv.json @@ -12,7 +12,8 @@ "user": { "data": { "api_key": "API nyckel" - } + }, + "title": "Anslut till Rachio-enheten" } } } diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index c175117efcb..94c79a1504f 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -1,7 +1,4 @@ """Webhooks used by rachio.""" - -import logging - from aiohttp import web from homeassistant.const import URL_API @@ -80,9 +77,6 @@ SIGNAL_MAP = { } -_LOGGER = logging.getLogger(__name__) - - @callback def async_register_webhook(hass, webhook_id, entry_id): """Register a webhook.""" diff --git a/homeassistant/components/rainmachine/translations/ko.json b/homeassistant/components/rainmachine/translations/ko.json index e1c78ae8247..08ccfd1f5b9 100644 --- a/homeassistant/components/rainmachine/translations/ko.json +++ b/homeassistant/components/rainmachine/translations/ko.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 RainMachine \ucee8\ud2b8\ub864\ub7ec\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/rainmachine/translations/nl.json b/homeassistant/components/rainmachine/translations/nl.json index adaa8cb5f30..119e4c641af 100644 --- a/homeassistant/components/rainmachine/translations/nl.json +++ b/homeassistant/components/rainmachine/translations/nl.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "Deze RainMachine controller is al geconfigureerd." + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" }, "step": { "user": { @@ -13,5 +16,12 @@ "title": "Vul uw gegevens in" } } + }, + "options": { + "step": { + "init": { + "title": "Configureer RainMachine" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rainmachine/translations/ru.json b/homeassistant/components/rainmachine/translations/ru.json index 08ce690d22f..8502b66aff7 100644 --- a/homeassistant/components/rainmachine/translations/ru.json +++ b/homeassistant/components/rainmachine/translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index d66c2aae0e4..66ced51b77f 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_FRIENDLY_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_FRIENDLY_NAME, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.update_coordinator import ( @@ -25,8 +25,6 @@ DEFAULT_ATTRIBUTION = "Pickup data provided by ReCollect Waste" DEFAULT_NAME = "recollect_waste" DEFAULT_ICON = "mdi:trash-can-outline" -CONF_NAME = "name" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_PLACE_ID): cv.string, diff --git a/homeassistant/components/recollect_waste/translations/fr.json b/homeassistant/components/recollect_waste/translations/fr.json new file mode 100644 index 00000000000..dc62c8f520a --- /dev/null +++ b/homeassistant/components/recollect_waste/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + }, + "error": { + "invalid_place_or_service_id": "ID de lieu ou de service non valide" + }, + "step": { + "user": { + "data": { + "place_id": "Identifiant de lieu", + "service_id": "ID de service" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "friendly_name": "Utilisez des noms conviviaux pour les types de ramassage (si possible)" + }, + "title": "Configurer Recollect Waste" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recollect_waste/translations/ko.json b/homeassistant/components/recollect_waste/translations/ko.json new file mode 100644 index 00000000000..17dee71d640 --- /dev/null +++ b/homeassistant/components/recollect_waste/translations/ko.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recollect_waste/translations/nl.json b/homeassistant/components/recollect_waste/translations/nl.json new file mode 100644 index 00000000000..6ce4a8f8a9f --- /dev/null +++ b/homeassistant/components/recollect_waste/translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_place_or_service_id": "Ongeldige plaats of service-ID" + }, + "step": { + "user": { + "data": { + "place_id": "Plaats-ID", + "service_id": "Service-ID" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Configureer Recollect Waste" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 0f8a5ae7f8f..3935aa97eb8 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -5,6 +5,7 @@ import concurrent.futures from datetime import datetime import logging import queue +import sqlite3 import threading import time from typing import Any, Callable, List, Optional @@ -37,11 +38,18 @@ import homeassistant.util.dt as dt_util from . import migration, purge from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import Base, Events, RecorderRuns, States -from .util import session_scope, validate_or_move_away_sqlite_database +from .util import ( + dburl_to_path, + move_away_broken_database, + session_scope, + validate_or_move_away_sqlite_database, +) _LOGGER = logging.getLogger(__name__) SERVICE_PURGE = "purge" +SERVICE_ENABLE = "enable" +SERVICE_DISABLE = "disable" ATTR_KEEP_DAYS = "keep_days" ATTR_REPACK = "repack" @@ -52,6 +60,8 @@ SERVICE_PURGE_SCHEMA = vol.Schema( vol.Optional(ATTR_REPACK, default=False): cv.boolean, } ) +SERVICE_ENABLE_SCHEMA = vol.Schema({}) +SERVICE_DISABLE_SCHEMA = vol.Schema({}) DEFAULT_URL = "sqlite:///{hass_config_path}" DEFAULT_DB_FILE = "home-assistant_v2.db" @@ -193,6 +203,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, SERVICE_PURGE, async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA ) + async def async_handle_enable_sevice(service): + instance.set_enable(True) + + hass.services.async_register( + DOMAIN, SERVICE_ENABLE, async_handle_enable_sevice, schema=SERVICE_ENABLE_SCHEMA + ) + + async def async_handle_disable_service(service): + instance.set_enable(False) + + hass.services.async_register( + DOMAIN, + SERVICE_DISABLE, + async_handle_disable_service, + schema=SERVICE_DISABLE_SCHEMA, + ) + return await instance.async_db_ready @@ -247,12 +274,33 @@ class Recorder(threading.Thread): self._pending_expunge = [] self.event_session = None self.get_session = None - self._completed_database_setup = False + self._completed_database_setup = None + + self.enabled = True + + def set_enable(self, enable): + """Enable or disable recording events and states.""" + self.enabled = enable @callback def async_initialize(self): """Initialize the recorder.""" - self.hass.bus.async_listen(MATCH_ALL, self.event_listener) + self.hass.bus.async_listen( + MATCH_ALL, self.event_listener, event_filter=self._async_event_filter + ) + + @callback + def _async_event_filter(self, event): + """Filter events.""" + if event.event_type in self.exclude_t: + return False + + entity_id = event.data.get(ATTR_ENTITY_ID) + if entity_id is not None: + if not self.entity_filter(entity_id): + return False + + return True def do_adhoc_purge(self, **kwargs): """Trigger an adhoc purge retaining keep_days worth of data.""" @@ -263,39 +311,8 @@ class Recorder(threading.Thread): def run(self): """Start processing events to save.""" - tries = 1 - connected = False - while not connected and tries <= self.db_max_retries: - if tries != 1: - time.sleep(self.db_retry_wait) - try: - self._setup_connection() - migration.migrate_schema(self) - self._setup_run() - connected = True - _LOGGER.debug("Connected to recorder database") - except Exception as err: # pylint: disable=broad-except - _LOGGER.error( - "Error during connection setup: %s (retrying in %s seconds)", - err, - self.db_retry_wait, - ) - tries += 1 - - if not connected: - - @callback - def connection_failed(): - """Connect failed tasks.""" - self.async_db_ready.set_result(False) - persistent_notification.async_create( - self.hass, - "The recorder could not start, please check the log", - "Recorder", - ) - - self.hass.add_job(connection_failed) + if not self._setup_recorder(): return shutdown_task = object() @@ -333,6 +350,9 @@ class Recorder(threading.Thread): # If shutdown happened before Home Assistant finished starting if result is shutdown_task: + # Make sure we cleanly close the run if + # we restart before startup finishes + self._shutdown() return # Start periodic purge @@ -348,184 +368,183 @@ class Recorder(threading.Thread): async_purge, hour=4, minute=12, second=0 ) - self.event_session = self.get_session() - self.event_session.expire_on_commit = False + _LOGGER.debug("Recorder processing the queue") # Use a session for the event read loop # with a commit every time the event time # has changed. This reduces the disk io. while True: event = self.queue.get() + if event is None: - self._close_run() - self._close_connection() + self._shutdown() return - if isinstance(event, PurgeTask): - # Schedule a new purge task if this one didn't finish - if not purge.purge_old_data(self, event.keep_days, event.repack): - self.queue.put(PurgeTask(event.keep_days, event.repack)) - continue - if isinstance(event, WaitTask): - self._queue_watch.set() - continue - if event.event_type == EVENT_TIME_CHANGED: - self._keepalive_count += 1 - if self._keepalive_count >= KEEPALIVE_TIME: - self._keepalive_count = 0 - self._send_keep_alive() - if self.commit_interval: - self._timechanges_seen += 1 - if self._timechanges_seen >= self.commit_interval: - self._timechanges_seen = 0 - self._commit_event_session_or_retry() - continue - if event.event_type in self.exclude_t: - continue - entity_id = event.data.get(ATTR_ENTITY_ID) - if entity_id is not None: - if not self.entity_filter(entity_id): - continue + self._process_one_event(event) + def _setup_recorder(self) -> bool: + """Create schema and connect to the database.""" + tries = 1 + + while tries <= self.db_max_retries: try: - if event.event_type == EVENT_STATE_CHANGED: - dbevent = Events.from_event(event, event_data="{}") - else: - dbevent = Events.from_event(event) - dbevent.created = event.time_fired - self.event_session.add(dbevent) - except (TypeError, ValueError): - _LOGGER.warning("Event is not JSON serializable: %s", event) + self._setup_connection() + migration.migrate_schema(self) + self._setup_run() except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error adding event: %s", err) + _LOGGER.error( + "Error during connection setup to %s: %s (retrying in %s seconds)", + self.db_url, + err, + self.db_retry_wait, + ) + else: + _LOGGER.debug("Connected to recorder database") + self._open_event_session() + return True - if dbevent and event.event_type == EVENT_STATE_CHANGED: - try: - dbstate = States.from_event(event) - has_new_state = event.data.get("new_state") - if dbstate.entity_id in self._old_states: - old_state = self._old_states.pop(dbstate.entity_id) - if old_state.state_id: - dbstate.old_state_id = old_state.state_id - else: - dbstate.old_state = old_state - if not has_new_state: - dbstate.state = None - dbstate.event = dbevent - dbstate.created = event.time_fired - self.event_session.add(dbstate) - if has_new_state: - self._old_states[dbstate.entity_id] = dbstate - self._pending_expunge.append(dbstate) - except (TypeError, ValueError): - _LOGGER.warning( - "State is not JSON serializable: %s", - event.data.get("new_state"), - ) - except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error adding state change: %s", err) + tries += 1 + time.sleep(self.db_retry_wait) - # If they do not have a commit interval - # than we commit right away - if not self.commit_interval: - self._commit_event_session_or_retry() + @callback + def connection_failed(): + """Connect failed tasks.""" + self.async_db_ready.set_result(False) + persistent_notification.async_create( + self.hass, + "The recorder could not start, please check the log", + "Recorder", + ) + + self.hass.add_job(connection_failed) + return False + + def _process_one_event(self, event): + """Process one event.""" + if isinstance(event, PurgeTask): + # Schedule a new purge task if this one didn't finish + if not purge.purge_old_data(self, event.keep_days, event.repack): + self.queue.put(PurgeTask(event.keep_days, event.repack)) + return + if isinstance(event, WaitTask): + self._queue_watch.set() + return + if event.event_type == EVENT_TIME_CHANGED: + self._keepalive_count += 1 + if self._keepalive_count >= KEEPALIVE_TIME: + self._keepalive_count = 0 + self._send_keep_alive() + if self.commit_interval: + self._timechanges_seen += 1 + if self._timechanges_seen >= self.commit_interval: + self._timechanges_seen = 0 + self._commit_event_session_or_recover() + return + + if not self.enabled: + return - def _send_keep_alive(self): try: - _LOGGER.debug("Sending keepalive") - self.event_session.connection().scalar(select([1])) + if event.event_type == EVENT_STATE_CHANGED: + dbevent = Events.from_event(event, event_data="{}") + else: + dbevent = Events.from_event(event) + dbevent.created = event.time_fired + self.event_session.add(dbevent) + except (TypeError, ValueError): + _LOGGER.warning("Event is not JSON serializable: %s", event) return except Exception as err: # pylint: disable=broad-except # Must catch the exception to prevent the loop from collapsing - _LOGGER.error( - "Error in database connectivity during keepalive: %s", - err, - ) - self._reopen_event_session() + _LOGGER.exception("Error adding event: %s", err) + return + + if event.event_type == EVENT_STATE_CHANGED: + try: + dbstate = States.from_event(event) + has_new_state = event.data.get("new_state") + if dbstate.entity_id in self._old_states: + old_state = self._old_states.pop(dbstate.entity_id) + if old_state.state_id: + dbstate.old_state_id = old_state.state_id + else: + dbstate.old_state = old_state + if not has_new_state: + dbstate.state = None + dbstate.event = dbevent + dbstate.created = event.time_fired + self.event_session.add(dbstate) + if has_new_state: + self._old_states[dbstate.entity_id] = dbstate + self._pending_expunge.append(dbstate) + except (TypeError, ValueError): + _LOGGER.warning( + "State is not JSON serializable: %s", + event.data.get("new_state"), + ) + except Exception as err: # pylint: disable=broad-except + # Must catch the exception to prevent the loop from collapsing + _LOGGER.exception("Error adding state change: %s", err) + + # If they do not have a commit interval + # than we commit right away + if not self.commit_interval: + self._commit_event_session_or_recover() + + def _commit_event_session_or_recover(self): + """Commit changes to the database and recover if the database fails when possible.""" + try: + self._commit_event_session_or_retry() + return + except exc.DatabaseError as err: + if isinstance(err.__cause__, sqlite3.DatabaseError): + _LOGGER.exception( + "Unrecoverable sqlite3 database corruption detected: %s", err + ) + self._handle_sqlite_corruption() + return + _LOGGER.exception("Unexpected error saving events: %s", err) + except Exception as err: # pylint: disable=broad-except + # Must catch the exception to prevent the loop from collapsing + _LOGGER.exception("Unexpected error saving events: %s", err) + + self._reopen_event_session() + return def _commit_event_session_or_retry(self): tries = 1 while tries <= self.db_max_retries: - if tries != 1: - time.sleep(self.db_retry_wait) - try: self._commit_event_session() return except (exc.InternalError, exc.OperationalError) as err: if err.connection_invalidated: - _LOGGER.error( - "Database connection invalidated: %s. " - "(retrying in %s seconds)", - err, - self.db_retry_wait, - ) + message = "Database connection invalidated" else: - _LOGGER.error( - "Error in database connectivity during commit: %s. " - "(retrying in %s seconds)", - err, - self.db_retry_wait, - ) + message = "Error in database connectivity during commit" + _LOGGER.error( + "%s: Error executing query: %s. (retrying in %s seconds)", + message, + err, + self.db_retry_wait, + ) + if tries == self.db_max_retries: + raise + tries += 1 - - except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error saving events: %s", err) - return - - _LOGGER.error( - "Error in database update. Could not save " "after %d tries. Giving up", - tries, - ) - self._reopen_event_session() - - def _reopen_event_session(self): - try: - self.event_session.rollback() - except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error while rolling back event session: %s", err) - - try: - self.event_session.close() - except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error while closing event session: %s", err) - - try: - self.event_session = self.get_session() - self.event_session.expire_on_commit = False - except Exception as err: # pylint: disable=broad-except - # Must catch the exception to prevent the loop from collapsing - _LOGGER.exception("Error while creating new event session: %s", err) + time.sleep(self.db_retry_wait) def _commit_event_session(self): self._commits_without_expire += 1 - try: - if self._pending_expunge: - self.event_session.flush() - for dbstate in self._pending_expunge: - # Expunge the state so its not expired - # until we use it later for dbstate.old_state - if dbstate in self.event_session: - self.event_session.expunge(dbstate) - self._pending_expunge = [] - self.event_session.commit() - except exc.IntegrityError as err: - _LOGGER.error( - "Integrity error executing query (database likely deleted out from under us): %s", - err, - ) - self.event_session.rollback() - self._old_states = {} - raise - except Exception as err: - _LOGGER.error("Error executing query: %s", err) - self.event_session.rollback() - raise + if self._pending_expunge: + self.event_session.flush() + for dbstate in self._pending_expunge: + # Expunge the state so its not expired + # until we use it later for dbstate.old_state + if dbstate in self.event_session: + self.event_session.expunge(dbstate) + self._pending_expunge = [] + self.event_session.commit() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only @@ -534,6 +553,47 @@ class Recorder(threading.Thread): self._commits_without_expire = 0 self.event_session.expire_all() + def _handle_sqlite_corruption(self): + """Handle the sqlite3 database being corrupt.""" + self._close_connection() + move_away_broken_database(dburl_to_path(self.db_url)) + self._setup_recorder() + + def _reopen_event_session(self): + """Rollback the event session and reopen it after a failure.""" + self._old_states = {} + + try: + self.event_session.rollback() + self.event_session.close() + except Exception as err: # pylint: disable=broad-except + # Must catch the exception to prevent the loop from collapsing + _LOGGER.exception( + "Error while rolling back and closing the event session: %s", err + ) + + self._open_event_session() + + def _open_event_session(self): + """Open the event session.""" + try: + self.event_session = self.get_session() + self.event_session.expire_on_commit = False + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error while creating new event session: %s", err) + + def _send_keep_alive(self): + try: + _LOGGER.debug("Sending keepalive") + self.event_session.connection().scalar(select([1])) + return + except Exception as err: # pylint: disable=broad-except + _LOGGER.error( + "Error in database connectivity during keepalive: %s", + err, + ) + self._reopen_event_session() + @callback def event_listener(self, event): """Listen for new events and put them in the process queue.""" @@ -558,6 +618,7 @@ class Recorder(threading.Thread): def _setup_connection(self): """Ensure database is ready to fly.""" kwargs = {} + self._completed_database_setup = False def setup_recorder_connection(dbapi_connection, connection_record): """Dbapi specific connection settings.""" @@ -590,9 +651,7 @@ class Recorder(threading.Thread): else: kwargs["echo"] = False - if self.db_url != SQLITE_URL_PREFIX and self.db_url.startswith( - SQLITE_URL_PREFIX - ): + if self._using_file_sqlite: with self.hass.timeout.freeze(DOMAIN): # # Here we run an sqlite3 quick_check. In the majority @@ -615,6 +674,13 @@ class Recorder(threading.Thread): Base.metadata.create_all(self.engine) self.get_session = scoped_session(sessionmaker(bind=self.engine)) + @property + def _using_file_sqlite(self): + """Short version to check if we are using sqlite3 as a file.""" + return self.db_url != SQLITE_URL_PREFIX and self.db_url.startswith( + SQLITE_URL_PREFIX + ) + def _close_connection(self): """Close the connection.""" self.engine.dispose() @@ -639,12 +705,18 @@ class Recorder(threading.Thread): session.flush() session.expunge(self.run_info) - def _close_run(self): + def _shutdown(self): """Save end time for current run.""" if self.event_session is not None: self.run_info.end = dt_util.utcnow() self.event_session.add(self.run_info) - self._commit_event_session_or_retry() - self.event_session.close() + try: + self._commit_event_session_or_retry() + self.event_session.close() + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception( + "Error saving the event session during shutdown: %s", err + ) self.run_info = None + self._close_connection() diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 67d3bdd0f5b..a7e5eb0814d 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.3.22"], + "requirements": ["sqlalchemy==1.3.23"], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 512807c9f69..2be5b0e095e 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -1,11 +1,32 @@ # Describes the format for available recorder services purge: - description: Start purge task - delete events and states older than x days, according to keep_days service data. + name: Purge + description: Start purge task - to clean up old data from your database. fields: keep_days: - description: Number of history days to keep in database after purge. Value >= 0. + name: Days to keep + description: Number of history days to keep in database after purge. example: 2 + selector: + number: + min: 0 + max: 365 + step: 1 + unit_of_measurement: days + mode: slider + repack: - description: Attempt to save disk space by rewriting the entire database file. + name: Repack + description: + Attempt to save disk space by rewriting the entire database file. example: true + default: false + selector: + boolean: + +disable: + description: Stop the recording of events and state changes + +enabled: + description: Start the recording of events and state changes diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index abf14268687..b945386de82 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -112,19 +112,24 @@ def execute(qry, to_native=False, validate_entity_ids=True): def validate_or_move_away_sqlite_database(dburl: str, db_integrity_check: bool) -> bool: """Ensure that the database is valid or move it away.""" - dbpath = dburl[len(SQLITE_URL_PREFIX) :] + dbpath = dburl_to_path(dburl) if not os.path.exists(dbpath): # Database does not exist yet, this is OK return True if not validate_sqlite_database(dbpath, db_integrity_check): - _move_away_broken_database(dbpath) + move_away_broken_database(dbpath) return False return True +def dburl_to_path(dburl): + """Convert the db url into a filesystem path.""" + return dburl[len(SQLITE_URL_PREFIX) :] + + def last_run_was_recently_clean(cursor): """Verify the last recorder run was recently clean.""" @@ -171,7 +176,10 @@ def validate_sqlite_database(dbpath: str, db_integrity_check: bool) -> bool: def run_checks_on_open_db(dbpath, cursor, db_integrity_check): """Run checks that will generate a sqlite3 exception if there is corruption.""" - if basic_sanity_check(cursor) and last_run_was_recently_clean(cursor): + sanity_check_passed = basic_sanity_check(cursor) + last_run_was_clean = last_run_was_recently_clean(cursor) + + if sanity_check_passed and last_run_was_clean: _LOGGER.debug( "The quick_check will be skipped as the system was restarted cleanly and passed the basic sanity check" ) @@ -187,13 +195,25 @@ def run_checks_on_open_db(dbpath, cursor, db_integrity_check): ) return - _LOGGER.debug( + if not sanity_check_passed: + _LOGGER.warning( + "The database sanity check failed to validate the sqlite3 database at %s", + dbpath, + ) + + if not last_run_was_clean: + _LOGGER.warning( + "The system could not validate that the sqlite3 database at %s was shutdown cleanly.", + dbpath, + ) + + _LOGGER.info( "A quick_check is being performed on the sqlite3 database at %s", dbpath ) cursor.execute("PRAGMA QUICK_CHECK") -def _move_away_broken_database(dbfile: str) -> None: +def move_away_broken_database(dbfile: str) -> None: """Move away a broken sqlite3 database.""" isotime = dt_util.utcnow().isoformat() diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json index fc3356b310c..252052ac5c2 100644 --- a/homeassistant/components/reddit/manifest.json +++ b/homeassistant/components/reddit/manifest.json @@ -2,6 +2,6 @@ "domain": "reddit", "name": "Reddit", "documentation": "https://www.home-assistant.io/integrations/reddit", - "requirements": ["praw==7.1.0"], + "requirements": ["praw==7.1.4"], "codeowners": [] } diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 0fe4e87f863..7a04fb6a8ae 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( + ATTR_ID, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_MAXIMUM, @@ -21,7 +22,6 @@ _LOGGER = logging.getLogger(__name__) CONF_SORT_BY = "sort_by" CONF_SUBREDDITS = "subreddits" -ATTR_ID = "id" ATTR_BODY = "body" ATTR_COMMENTS_NUMBER = "comms_num" ATTR_CREATED = "created" diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index f03f88023ae..8ce8cb98e5b 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -2,7 +2,7 @@ "domain": "remember_the_milk", "name": "Remember The Milk", "documentation": "https://www.home-assistant.io/integrations/remember_the_milk", - "requirements": ["RtmAPI==0.7.2", "httplib2==0.18.1"], + "requirements": ["RtmAPI==0.7.2", "httplib2==0.19.0"], "dependencies": ["configurator"], "codeowners": [] } diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 69bc6172341..ebeddcfd7c7 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -1,4 +1,174 @@ """The rest component.""" -DOMAIN = "rest" +import asyncio +import logging + +import httpx +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HEADERS, + CONF_METHOD, + CONF_PARAMS, + CONF_PASSWORD, + CONF_PAYLOAD, + CONF_RESOURCE, + CONF_RESOURCE_TEMPLATE, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery +from homeassistant.helpers.entity_component import ( + DEFAULT_SCAN_INTERVAL, + EntityComponent, +) +from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_IDX +from .data import RestData +from .schema import CONFIG_SCHEMA # noqa:F401 pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ["binary_sensor", "notify", "sensor", "switch"] +COORDINATOR_AWARE_PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the rest platforms.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + _async_setup_shared_data(hass) + + 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: + return + await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) + _async_setup_shared_data(hass) + await _async_process_config(hass, conf) + + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + + return await _async_process_config(hass, config) + + +@callback +def _async_setup_shared_data(hass: HomeAssistant): + """Create shared data for platform config and rest coordinators.""" + hass.data[DOMAIN] = {platform: {} for platform in COORDINATOR_AWARE_PLATFORMS} + + +async def _async_process_config(hass, config) -> bool: + """Process rest configuration.""" + if DOMAIN not in config: + return True + + refresh_tasks = [] + load_tasks = [] + for rest_idx, conf in enumerate(config[DOMAIN]): + scan_interval = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + rest = create_rest_data_from_config(hass, conf) + coordinator = _wrap_rest_in_coordinator( + hass, rest, resource_template, scan_interval + ) + refresh_tasks.append(coordinator.async_refresh()) + hass.data[DOMAIN][rest_idx] = {REST: rest, COORDINATOR: coordinator} + + for platform_domain in COORDINATOR_AWARE_PLATFORMS: + if platform_domain not in conf: + continue + + for platform_idx, platform_conf in enumerate(conf[platform_domain]): + hass.data[DOMAIN][platform_domain][platform_idx] = platform_conf + + load = discovery.async_load_platform( + hass, + platform_domain, + DOMAIN, + {REST_IDX: rest_idx, PLATFORM_IDX: platform_idx}, + config, + ) + load_tasks.append(load) + + if refresh_tasks: + await asyncio.gather(*refresh_tasks) + + if load_tasks: + await asyncio.gather(*load_tasks) + + return True + + +async def async_get_config_and_coordinator(hass, platform_domain, discovery_info): + """Get the config and coordinator for the platform from discovery.""" + shared_data = hass.data[DOMAIN][discovery_info[REST_IDX]] + conf = hass.data[DOMAIN][platform_domain][discovery_info[PLATFORM_IDX]] + coordinator = shared_data[COORDINATOR] + rest = shared_data[REST] + if rest.data is None: + await coordinator.async_request_refresh() + return conf, coordinator, rest + + +def _wrap_rest_in_coordinator(hass, rest, resource_template, update_interval): + """Wrap a DataUpdateCoordinator around the rest object.""" + if resource_template: + + async def _async_refresh_with_resource_template(): + rest.set_url(resource_template.async_render(parse_result=False)) + await rest.async_update() + + update_method = _async_refresh_with_resource_template + else: + update_method = rest.async_update + + return DataUpdateCoordinator( + hass, + _LOGGER, + name="rest data", + update_method=update_method, + update_interval=update_interval, + ) + + +def create_rest_data_from_config(hass, config): + """Create RestData from config.""" + resource = config.get(CONF_RESOURCE) + resource_template = config.get(CONF_RESOURCE_TEMPLATE) + method = config.get(CONF_METHOD) + payload = config.get(CONF_PAYLOAD) + verify_ssl = config.get(CONF_VERIFY_SSL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + headers = config.get(CONF_HEADERS) + params = config.get(CONF_PARAMS) + timeout = config.get(CONF_TIMEOUT) + + if resource_template is not None: + resource_template.hass = hass + resource = resource_template.async_render(parse_result=False) + + if username and password: + if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = httpx.DigestAuth(username, password) + else: + auth = (username, password) + else: + auth = None + + return RestData( + hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + ) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 49c10354c51..9692f5b9339 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,64 +1,27 @@ """Support for RESTful binary sensors.""" -import httpx import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, BinarySensorEntity, ) from homeassistant.const import ( - CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_HEADERS, - CONF_METHOD, CONF_NAME, - CONF_PARAMS, - CONF_PASSWORD, - CONF_PAYLOAD, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_TIMEOUT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS -from .data import DEFAULT_TIMEOUT, RestData +from . import async_get_config_and_coordinator, create_rest_data_from_config +from .entity import RestEntity +from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA -DEFAULT_METHOD = "GET" -DEFAULT_NAME = "REST Binary Sensor" -DEFAULT_VERIFY_SSL = True -DEFAULT_FORCE_UPDATE = False - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, - vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, - vol.Optional(CONF_PARAMS): {cv.string: cv.string}, - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(["POST", "GET"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **BINARY_SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA @@ -67,51 +30,34 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the REST binary sensor.""" - - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name = config.get(CONF_NAME) - resource = config.get(CONF_RESOURCE) - resource_template = config.get(CONF_RESOURCE_TEMPLATE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - timeout = config.get(CONF_TIMEOUT) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - force_update = config.get(CONF_FORCE_UPDATE) - - if resource_template is not None: - resource_template.hass = hass - resource = resource_template.async_render(parse_result=False) - - if value_template is not None: - value_template.hass = hass - - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. + if discovery_info is not None: + conf, coordinator, rest = await async_get_config_and_coordinator( + hass, BINARY_SENSOR_DOMAIN, discovery_info + ) else: - auth = None - - rest = RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout - ) - await rest.async_update() + conf = config + coordinator = None + rest = create_rest_data_from_config(hass, conf) + await rest.async_update() if rest.data is None: raise PlatformNotReady + name = conf.get(CONF_NAME) + device_class = conf.get(CONF_DEVICE_CLASS) + value_template = conf.get(CONF_VALUE_TEMPLATE) + force_update = conf.get(CONF_FORCE_UPDATE) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + async_add_entities( [ RestBinarySensor( - hass, + coordinator, rest, name, device_class, @@ -123,12 +69,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -class RestBinarySensor(BinarySensorEntity): +class RestBinarySensor(RestEntity, BinarySensorEntity): """Representation of a REST binary sensor.""" def __init__( self, - hass, + coordinator, rest, name, device_class, @@ -137,36 +83,23 @@ class RestBinarySensor(BinarySensorEntity): resource_template, ): """Initialize a REST binary sensor.""" - self._hass = hass - self.rest = rest - self._name = name - self._device_class = device_class + super().__init__( + coordinator, rest, name, device_class, resource_template, force_update + ) self._state = False self._previous_data = None self._value_template = value_template - self._force_update = force_update - self._resource_template = resource_template - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def available(self): - """Return the availability of this sensor.""" - return self.rest.data is not None + self._is_on = None @property def is_on(self): """Return true if the binary sensor is on.""" + return self._is_on + + def _update_from_rest_data(self): + """Update state from the rest data.""" if self.rest.data is None: - return False + self._is_on = False response = self.rest.data @@ -176,20 +109,8 @@ class RestBinarySensor(BinarySensorEntity): ) try: - return bool(int(response)) + self._is_on = bool(int(response)) except ValueError: - return {"true": True, "on": True, "open": True, "yes": True}.get( + self._is_on = {"true": True, "on": True, "open": True, "yes": True}.get( response.lower(), False ) - - @property - def force_update(self): - """Force update.""" - return self._force_update - - async def async_update(self): - """Get the latest data from REST API and updates the state.""" - if self._resource_template is not None: - self.rest.set_url(self._resource_template.async_render(parse_result=False)) - - await self.rest.async_update() diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py new file mode 100644 index 00000000000..31216b65968 --- /dev/null +++ b/homeassistant/components/rest/const.py @@ -0,0 +1,20 @@ +"""The rest component constants.""" + +DOMAIN = "rest" + +DEFAULT_METHOD = "GET" +DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False + +DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" +DEFAULT_SENSOR_NAME = "REST Sensor" +CONF_JSON_ATTRS = "json_attributes" +CONF_JSON_ATTRS_PATH = "json_attributes_path" + +REST_IDX = "rest_idx" +PLATFORM_IDX = "platform_idx" + +COORDINATOR = "coordinator" +REST = "rest" + +METHODS = ["POST", "GET"] diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py new file mode 100644 index 00000000000..acfe5a2dfc5 --- /dev/null +++ b/homeassistant/components/rest/entity.py @@ -0,0 +1,89 @@ +"""The base entity for the rest component.""" + +from abc import abstractmethod +from typing import Any + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .data import RestData + + +class RestEntity(Entity): + """A class for entities using DataUpdateCoordinator or rest data directly.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + rest: RestData, + name, + device_class, + resource_template, + force_update, + ) -> None: + """Create the entity that may have a coordinator.""" + self.coordinator = coordinator + self.rest = rest + self._name = name + self._device_class = device_class + self._resource_template = resource_template + self._force_update = force_update + super().__init__() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def force_update(self): + """Force update.""" + return self._force_update + + @property + def should_poll(self) -> bool: + """Poll only if we do noty have a coordinator.""" + return not self.coordinator + + @property + def available(self): + """Return the availability of this sensor.""" + if self.coordinator and not self.coordinator.last_update_success: + return False + return self.rest.data is not None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_from_rest_data() + if self.coordinator: + self.async_on_remove( + self.coordinator.async_add_listener(self._handle_coordinator_update) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_rest_data() + self.async_write_ha_state() + + async def async_update(self): + """Get the latest data from REST API and update the state.""" + if self.coordinator: + await self.coordinator.async_request_refresh() + return + + if self._resource_template is not None: + self.rest.set_url(self._resource_template.async_render(parse_result=False)) + await self.rest.async_update() + self._update_from_rest_data() + + @abstractmethod + def _update_from_rest_data(self): + """Update state from the rest data.""" diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index f15df428640..198e5b06c52 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -29,11 +29,8 @@ from homeassistant.const import ( HTTP_OK, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.template import Template -from . import DOMAIN, PLATFORMS - CONF_DATA = "data" CONF_DATA_TEMPLATE = "data_template" CONF_MESSAGE_PARAMETER_NAME = "message_param_name" @@ -73,8 +70,6 @@ _LOGGER = logging.getLogger(__name__) def get_service(hass, config, discovery_info=None): """Get the RESTful notification service.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) - resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) headers = config.get(CONF_HEADERS) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py new file mode 100644 index 00000000000..bedd02d272a --- /dev/null +++ b/homeassistant/components/rest/schema.py @@ -0,0 +1,99 @@ +"""The rest component schemas.""" + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, +) +from homeassistant.components.sensor import ( + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_HEADERS, + CONF_METHOD, + CONF_NAME, + CONF_PARAMS, + CONF_PASSWORD, + CONF_PAYLOAD, + CONF_RESOURCE, + CONF_RESOURCE_TEMPLATE, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_JSON_ATTRS, + CONF_JSON_ATTRS_PATH, + DEFAULT_BINARY_SENSOR_NAME, + DEFAULT_FORCE_UPDATE, + DEFAULT_METHOD, + DEFAULT_SENSOR_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, + METHODS, +) +from .data import DEFAULT_TIMEOUT + +RESOURCE_SCHEMA = { + vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, + vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, + 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_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +} + +SENSOR_SCHEMA = { + vol.Optional(CONF_NAME, default=DEFAULT_SENSOR_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, + vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, +} + +BINARY_SENSOR_SCHEMA = { + vol.Optional(CONF_NAME, default=DEFAULT_BINARY_SENSOR_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, +} + + +COMBINED_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + **RESOURCE_SCHEMA, + vol.Optional(SENSOR_DOMAIN): vol.All( + cv.ensure_list, [vol.Schema(SENSOR_SCHEMA)] + ), + vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( + cv.ensure_list, [vol.Schema(BINARY_SENSOR_SCHEMA)] + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [COMBINED_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 85d79b6b331..0699d9dc07c 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,76 +3,31 @@ import json import logging from xml.parsers.expat import ExpatError -import httpx from jsonpath import jsonpath import voluptuous as vol import xmltodict -from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA from homeassistant.const import ( - CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_HEADERS, - CONF_METHOD, CONF_NAME, - CONF_PARAMS, - CONF_PASSWORD, - CONF_PAYLOAD, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_TIMEOUT, CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS -from .data import DEFAULT_TIMEOUT, RestData +from . import async_get_config_and_coordinator, create_rest_data_from_config +from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH +from .entity import RestEntity +from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA _LOGGER = logging.getLogger(__name__) -DEFAULT_METHOD = "GET" -DEFAULT_NAME = "REST Sensor" -DEFAULT_VERIFY_SSL = True -DEFAULT_FORCE_UPDATE = False - - -CONF_JSON_ATTRS = "json_attributes" -CONF_JSON_ATTRS_PATH = "json_attributes_path" -METHODS = ["POST", "GET"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, - vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, - 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_JSON_ATTRS, default=[]): cv.ensure_list_csv, - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA @@ -81,55 +36,37 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the RESTful sensor.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name = config.get(CONF_NAME) - resource = config.get(CONF_RESOURCE) - resource_template = config.get(CONF_RESOURCE_TEMPLATE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - json_attrs = config.get(CONF_JSON_ATTRS) - json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) - force_update = config.get(CONF_FORCE_UPDATE) - timeout = config.get(CONF_TIMEOUT) - - if value_template is not None: - value_template.hass = hass - - if resource_template is not None: - resource_template.hass = hass - resource = resource_template.async_render(parse_result=False) - - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. + if discovery_info is not None: + conf, coordinator, rest = await async_get_config_and_coordinator( + hass, SENSOR_DOMAIN, discovery_info + ) else: - auth = None - rest = RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout - ) - - await rest.async_update() + conf = config + coordinator = None + rest = create_rest_data_from_config(hass, conf) + await rest.async_update() if rest.data is None: raise PlatformNotReady - # Must update the sensor now (including fetching the rest resource) to - # ensure it's updating its state. + name = conf.get(CONF_NAME) + unit = conf.get(CONF_UNIT_OF_MEASUREMENT) + device_class = conf.get(CONF_DEVICE_CLASS) + json_attrs = conf.get(CONF_JSON_ATTRS) + json_attrs_path = conf.get(CONF_JSON_ATTRS_PATH) + value_template = conf.get(CONF_VALUE_TEMPLATE) + force_update = conf.get(CONF_FORCE_UPDATE) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + async_add_entities( [ RestSensor( - hass, + coordinator, rest, name, unit, @@ -144,12 +81,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -class RestSensor(Entity): +class RestSensor(RestEntity): """Implementation of a REST sensor.""" def __init__( self, - hass, + coordinator, rest, name, unit_of_measurement, @@ -161,60 +98,30 @@ class RestSensor(Entity): json_attrs_path, ): """Initialize the REST sensor.""" - self._hass = hass - self.rest = rest - self._name = name + super().__init__( + coordinator, rest, name, device_class, resource_template, force_update + ) self._state = None self._unit_of_measurement = unit_of_measurement - self._device_class = device_class self._value_template = value_template self._json_attrs = json_attrs self._attributes = None - self._force_update = force_update - self._resource_template = resource_template self._json_attrs_path = json_attrs_path - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def available(self): - """Return if the sensor data are available.""" - return self.rest.data is not None - @property def state(self): """Return the state of the device.""" return self._state @property - def force_update(self): - """Force update.""" - return self._force_update - - async def async_update(self): - """Get the latest data from REST API and update the state.""" - if self._resource_template is not None: - self.rest.set_url(self._resource_template.async_render(parse_result=False)) - - await self.rest.async_update() - self._update_from_rest_data() - - async def async_added_to_hass(self): - """Ensure the data from the initial update is reflected in the state.""" - self._update_from_rest_data() + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes def _update_from_rest_data(self): """Update state from the rest data.""" @@ -273,8 +180,3 @@ class RestSensor(Entity): ) self._state = value - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes diff --git a/homeassistant/components/rest/services.yaml b/homeassistant/components/rest/services.yaml index 06baa8734f2..7e324670134 100644 --- a/homeassistant/components/rest/services.yaml +++ b/homeassistant/components/rest/services.yaml @@ -1,2 +1,2 @@ reload: - description: Reload all rest entities and notify services. + description: Reload all rest entities and notify services diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index b6bd759d0bf..e8ae1dee015 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -22,12 +22,8 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service - -from . import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) - CONF_BODY_OFF = "body_off" CONF_BODY_ON = "body_on" CONF_IS_ON_TEMPLATE = "is_on_template" @@ -40,7 +36,7 @@ DEFAULT_NAME = "REST Switch" DEFAULT_TIMEOUT = 10 DEFAULT_VERIFY_SSL = True -SUPPORT_REST_METHODS = ["post", "put"] +SUPPORT_REST_METHODS = ["post", "put", "patch"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -65,9 +61,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the RESTful switch.""" - - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - body_off = config.get(CONF_BODY_OFF) body_on = config.get(CONF_BODY_ON) is_on_template = config.get(CONF_IS_ON_TEMPLATE) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 116b2464213..3cff3beed3c 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -10,7 +10,9 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_STATE, CONF_COMMAND, + CONF_DEVICE_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, @@ -29,27 +31,26 @@ from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) ATTR_EVENT = "event" -ATTR_STATE = "state" CONF_ALIASES = "aliases" CONF_GROUP_ALIASES = "group_aliases" CONF_GROUP = "group" CONF_NOGROUP_ALIASES = "nogroup_aliases" CONF_DEVICE_DEFAULTS = "device_defaults" -CONF_DEVICE_ID = "device_id" -CONF_DEVICES = "devices" CONF_AUTOMATIC_ADD = "automatic_add" CONF_FIRE_EVENT = "fire_event" CONF_IGNORE_DEVICES = "ignore_devices" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_SIGNAL_REPETITIONS = "signal_repetitions" CONF_WAIT_FOR_ACK = "wait_for_ack" +CONF_KEEPALIVE_IDLE = "tcp_keepalive_idle_timer" DATA_DEVICE_REGISTER = "rflink_device_register" DATA_ENTITY_LOOKUP = "rflink_entity_lookup" DATA_ENTITY_GROUP_LOOKUP = "rflink_entity_group_only_lookup" DEFAULT_RECONNECT_INTERVAL = 10 DEFAULT_SIGNAL_REPETITIONS = 1 +DEFAULT_TCP_KEEPALIVE_IDLE_TIMER = 3600 CONNECTION_TIMEOUT = 10 EVENT_BUTTON_PRESSED = "button_pressed" @@ -85,6 +86,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_PORT): vol.Any(cv.port, cv.string), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean, + vol.Optional( + CONF_KEEPALIVE_IDLE, default=DEFAULT_TCP_KEEPALIVE_IDLE_TIMER + ): int, vol.Optional( CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL ): int, @@ -199,6 +203,26 @@ async def async_setup(hass, config): # TCP port when host configured, otherwise serial port port = config[DOMAIN][CONF_PORT] + # TCP KEEPALIVE will be enabled if value > 0 + keepalive_idle_timer = config[DOMAIN][CONF_KEEPALIVE_IDLE] + if keepalive_idle_timer < 0: + _LOGGER.error( + "A bogus TCP Keepalive IDLE timer was provided (%d secs), " + "default value will be used. " + "Recommended values: 60-3600 (seconds)", + keepalive_idle_timer, + ) + keepalive_idle_timer = DEFAULT_TCP_KEEPALIVE_IDLE_TIMER + elif keepalive_idle_timer == 0: + keepalive_idle_timer = None + elif keepalive_idle_timer <= 30: + _LOGGER.warning( + "A very short TCP Keepalive IDLE timer was provided (%d secs), " + "and may produce unexpected disconnections from RFlink device." + " Recommended values: 60-3600 (seconds)", + keepalive_idle_timer, + ) + @callback def reconnect(exc=None): """Schedule reconnect after connection has been unexpectedly lost.""" @@ -223,6 +247,7 @@ async def async_setup(hass, config): connection = create_rflink_connection( port=port, host=host, + keepalive=keepalive_idle_timer, event_callback=event_callback, disconnect_callback=reconnect, loop=hass.loop, diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index dd16343898d..77a8a522f65 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -6,11 +6,16 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_DEVICES, + CONF_FORCE_UPDATE, + CONF_NAME, +) import homeassistant.helpers.config_validation as cv import homeassistant.helpers.event as evt -from . import CONF_ALIASES, CONF_DEVICES, RflinkDevice +from . import CONF_ALIASES, RflinkDevice CONF_OFF_DELAY = "off_delay" DEFAULT_FORCE_UPDATE = False diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 5eacce3afa8..2e6837d21ea 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -4,14 +4,13 @@ import logging import voluptuous as vol from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity -from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_OPEN +from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE, STATE_OPEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, - CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 6d63e12378d..fe74c979396 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -9,14 +9,13 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, LightEntity, ) -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE import homeassistant.helpers.config_validation as cv from . import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS, - CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index cdcfe97c219..ebd1fb5afdc 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,6 +2,8 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.55"], - "codeowners": [] + "requirements": ["rflink==0.0.58"], + "codeowners": [ + "@javicalle" + ] } diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 2c27477e6c6..1a616c2ed90 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -5,7 +5,9 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + CONF_DEVICES, CONF_NAME, + CONF_SENSOR_TYPE, CONF_UNIT_OF_MEASUREMENT, ) import homeassistant.helpers.config_validation as cv @@ -14,7 +16,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, - CONF_DEVICES, DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, EVENT_KEY_ID, @@ -32,8 +33,6 @@ SENSOR_ICONS = { "temperature": "mdi:thermometer", } -CONF_SENSOR_TYPE = "sensor_type" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 77e1f821bad..8f84286a616 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -2,13 +2,12 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_DEVICES, CONF_NAME import homeassistant.helpers.config_validation as cv from . import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, - CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 067ffeb5313..5952cb62a71 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -3,6 +3,7 @@ import asyncio import binascii from collections import OrderedDict import copy +import functools import logging import RFXtrx as rfxtrxmod @@ -488,7 +489,8 @@ class RfxtrxEntity(RestoreEntity): self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( - f"{DOMAIN}_{CONF_REMOVE_DEVICE}_{self._device_id}", self.async_remove + f"{DOMAIN}_{CONF_REMOVE_DEVICE}_{self._device_id}", + functools.partial(self.async_remove, force_remove=True), ) ) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 5eeb9b38411..da4d6447e76 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -344,7 +344,9 @@ class OptionsFlow(config_entries.OptionsFlow): new_device_id = "_".join(x for x in new_device_data[CONF_DEVICE_ID]) entity_registry = await async_get_entity_registry(self.hass) - entity_entries = async_entries_for_device(entity_registry, old_device) + entity_entries = async_entries_for_device( + entity_registry, old_device, include_disabled_entities=True + ) entity_migration_map = {} for entry in entity_entries: unique_id = entry.unique_id diff --git a/homeassistant/components/rfxtrx/translations/fr.json b/homeassistant/components/rfxtrx/translations/fr.json index baf8d0f5148..c0df7233458 100644 --- a/homeassistant/components/rfxtrx/translations/fr.json +++ b/homeassistant/components/rfxtrx/translations/fr.json @@ -64,7 +64,8 @@ "off_delay": "D\u00e9lai d'arr\u00eat", "off_delay_enabled": "Activer le d\u00e9lai d'arr\u00eat", "replace_device": "S\u00e9lectionnez l'appareil \u00e0 remplacer", - "signal_repetitions": "Nombre de r\u00e9p\u00e9titions du signal" + "signal_repetitions": "Nombre de r\u00e9p\u00e9titions du signal", + "venetian_blind_mode": "Mode store v\u00e9nitien" }, "title": "Configurer les options de l'appareil" } diff --git a/homeassistant/components/rfxtrx/translations/ko.json b/homeassistant/components/rfxtrx/translations/ko.json index aa8512da285..e8c83a7bfd7 100644 --- a/homeassistant/components/rfxtrx/translations/ko.json +++ b/homeassistant/components/rfxtrx/translations/ko.json @@ -1,7 +1,30 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "setup_network": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + }, + "setup_serial_manual_path": { + "data": { + "device": "USB \uc7a5\uce58 \uacbd\ub85c" + } + } + } + }, + "options": { + "error": { + "already_configured_device": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" } } } \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/nl.json b/homeassistant/components/rfxtrx/translations/nl.json index 0dc56206f66..0b6e8997b18 100644 --- a/homeassistant/components/rfxtrx/translations/nl.json +++ b/homeassistant/components/rfxtrx/translations/nl.json @@ -10,10 +10,23 @@ "step": { "setup_network": { "data": { + "host": "Host", "port": "Poort" }, "title": "Selecteer verbindingsadres" }, + "setup_serial": { + "data": { + "device": "Selecteer apparaat" + }, + "title": "Apparaat" + }, + "setup_serial_manual_path": { + "data": { + "device": "USB-apparaatpad" + }, + "title": "Pad" + }, "user": { "data": { "type": "Verbindingstype" @@ -25,7 +38,19 @@ "options": { "error": { "already_configured_device": "Apparaat is al geconfigureerd", + "invalid_event_code": "Ongeldige gebeurteniscode", "unknown": "Onverwachte fout" + }, + "step": { + "prompt_options": { + "data": { + "automatic_add": "Schakel automatisch toevoegen in", + "debug": "Foutopsporing inschakelen" + } + }, + "set_device_options": { + "title": "Configureer apparaatopties" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ring/translations/ru.json b/homeassistant/components/ring/translations/ru.json index fb8c22c39af..636d83f2e02 100644 --- a/homeassistant/components/ring/translations/ru.json +++ b/homeassistant/components/ring/translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/risco/translations/et.json b/homeassistant/components/risco/translations/et.json index 9bd35ec22db..c57d5d73fec 100644 --- a/homeassistant/components/risco/translations/et.json +++ b/homeassistant/components/risco/translations/et.json @@ -27,7 +27,7 @@ "armed_home": "Valves kodus", "armed_night": "Valves \u00f6ine" }, - "description": "Valige millisesse olekusse l\u00fcltub Risco alarm kui valvestada Home Assistant", + "description": "Vali millisesse olekusse l\u00fcltub Risco alarm kui valvestada Home Assistant", "title": "Lisa Risco olekud Home Assistanti olekutesse" }, "init": { diff --git a/homeassistant/components/risco/translations/ko.json b/homeassistant/components/risco/translations/ko.json index 37d9a61307b..f3065256e7f 100644 --- a/homeassistant/components/risco/translations/ko.json +++ b/homeassistant/components/risco/translations/ko.json @@ -1,12 +1,21 @@ { "config": { "abort": { - "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "pin": "PIN \ucf54\ub4dc", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } } }, "options": { @@ -23,6 +32,8 @@ }, "init": { "data": { + "code_arm_required": "\uc124\uc815\ud558\ub824\uba74 PIN \ucf54\ub4dc\uac00 \ud544\uc694\ud569\ub2c8\ub2e4", + "code_disarm_required": "\ud574\uc81c\ud558\ub824\uba74 PIN \ucf54\ub4dc\uac00 \ud544\uc694\ud569\ub2c8\ub2e4", "scan_interval": "Risco\ub97c \ud3f4\ub9c1\ud558\ub294 \ube48\ub3c4 (\ucd08)" } }, diff --git a/homeassistant/components/risco/translations/nl.json b/homeassistant/components/risco/translations/nl.json index 34bcb4ab98a..97d0d454a4f 100644 --- a/homeassistant/components/risco/translations/nl.json +++ b/homeassistant/components/risco/translations/nl.json @@ -30,8 +30,8 @@ }, "init": { "data": { - "code_arm_required": "Pincode vereist om in te schakelen", - "code_disarm_required": "Pincode vereist om uit te schakelen" + "code_arm_required": "PIN-code vereist om in te schakelen", + "code_disarm_required": "PIN-code vereist om uit te schakelen" }, "title": "Configureer opties" }, diff --git a/homeassistant/components/risco/translations/pl.json b/homeassistant/components/risco/translations/pl.json index ef7ed9f13e0..b39c2cde23b 100644 --- a/homeassistant/components/risco/translations/pl.json +++ b/homeassistant/components/risco/translations/pl.json @@ -34,7 +34,7 @@ "data": { "code_arm_required": "Wymagaj kodu PIN do uzbrojenia", "code_disarm_required": "Wymagaj kodu PIN do rozbrojenia", - "scan_interval": "Cz\u0119stotliwo\u015b\u0107 od\u015bwie\u017cania (w sekundach)" + "scan_interval": "Jak cz\u0119sto odpytywa\u0107 Risco (w sekundach)" }, "title": "Opcje" }, diff --git a/homeassistant/components/risco/translations/ru.json b/homeassistant/components/risco/translations/ru.json index 3fd1fd567f9..a507bb84e53 100644 --- a/homeassistant/components/risco/translations/ru.json +++ b/homeassistant/components/risco/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py new file mode 100644 index 00000000000..ba11206d496 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -0,0 +1,61 @@ +"""The Rituals Perfume Genie integration.""" +import asyncio +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from pyrituals import Account + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ACCOUNT_HASH, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +EMPTY_CREDENTIALS = "" + +PLATFORMS = ["switch"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Rituals Perfume Genie component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Rituals Perfume Genie from a config entry.""" + session = async_get_clientsession(hass) + account = Account(EMPTY_CREDENTIALS, EMPTY_CREDENTIALS, session) + account.data = {ACCOUNT_HASH: entry.data.get(ACCOUNT_HASH)} + + try: + await account.get_devices() + except ClientConnectorError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = account + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py new file mode 100644 index 00000000000..59e442df538 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Rituals Perfume Genie integration.""" +import logging + +from aiohttp import ClientResponseError +from pyrituals import Account, AuthenticationException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ACCOUNT_HASH, DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rituals Perfume Genie.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + session = async_get_clientsession(self.hass) + account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session) + + try: + await account.authenticate() + except ClientResponseError: + errors["base"] = "cannot_connect" + except AuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(account.data[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=account.data[CONF_EMAIL], + data={ACCOUNT_HASH: account.data[ACCOUNT_HASH]}, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py new file mode 100644 index 00000000000..075d79ec8de --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -0,0 +1,5 @@ +"""Constants for the Rituals Perfume Genie integration.""" + +DOMAIN = "rituals_perfume_genie" + +ACCOUNT_HASH = "account_hash" diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json new file mode 100644 index 00000000000..8be7e98b939 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "rituals_perfume_genie", + "name": "Rituals Perfume Genie", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", + "requirements": [ + "pyrituals==0.0.2" + ], + "codeowners": [ + "@milanmeu" + ] +} diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json new file mode 100644 index 00000000000..8824923c313 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to your Rituals account", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py new file mode 100644 index 00000000000..7041d22f4b8 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -0,0 +1,92 @@ +"""Support for Rituals Perfume Genie switches.""" +from datetime import timedelta + +from homeassistant.components.switch import SwitchEntity + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +ON_STATE = "1" +AVAILABLE_STATE = 1 + +MANUFACTURER = "Rituals Cosmetics" +MODEL = "Diffuser" +ICON = "mdi:fan" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the diffuser switch.""" + account = hass.data[DOMAIN][config_entry.entry_id] + diffusers = await account.get_devices() + + entities = [] + for diffuser in diffusers: + entities.append(DiffuserSwitch(diffuser)) + + async_add_entities(entities, True) + + +class DiffuserSwitch(SwitchEntity): + """Representation of a diffuser switch.""" + + def __init__(self, diffuser): + """Initialize the switch.""" + self._diffuser = diffuser + + @property + def device_info(self): + """Return information about the device.""" + return { + "name": self._diffuser.data["hub"]["attributes"]["roomnamec"], + "identifiers": {(DOMAIN, self._diffuser.data["hub"]["hublot"])}, + "manufacturer": MANUFACTURER, + "model": MODEL, + "sw_version": self._diffuser.data["hub"]["sensors"]["versionc"], + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._diffuser.data["hub"]["hublot"] + + @property + def available(self): + """Return if the device is available.""" + return self._diffuser.data["hub"]["status"] == AVAILABLE_STATE + + @property + def name(self): + """Return the name of the device.""" + return self._diffuser.data["hub"]["attributes"]["roomnamec"] + + @property + def icon(self): + """Return the icon of the device.""" + return ICON + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = { + "fan_speed": self._diffuser.data["hub"]["attributes"]["speedc"], + "room_size": self._diffuser.data["hub"]["attributes"]["roomc"], + } + return attributes + + @property + def is_on(self): + """If the device is currently on or off.""" + return self._diffuser.data["hub"]["attributes"]["fanc"] == ON_STATE + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._diffuser.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._diffuser.turn_off() + + async def async_update(self): + """Update the data of the device.""" + await self._diffuser.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/translations/ca.json b/homeassistant/components/rituals_perfume_genie/translations/ca.json new file mode 100644 index 00000000000..d7abccc2c25 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "title": "Connexi\u00f3 amb el teu compte Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/de.json b/homeassistant/components/rituals_perfume_genie/translations/de.json new file mode 100644 index 00000000000..67b8ed59e0b --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/en.json b/homeassistant/components/rituals_perfume_genie/translations/en.json new file mode 100644 index 00000000000..21207b1e7ed --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Connect to your Rituals account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/es.json b/homeassistant/components/rituals_perfume_genie/translations/es.json new file mode 100644 index 00000000000..bc74ecfd7ea --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/es.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Con\u00e9ctese a su cuenta de Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/et.json b/homeassistant/components/rituals_perfume_genie/translations/et.json new file mode 100644 index 00000000000..17720a59792 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + }, + "title": "Loo \u00fchendus oma Ritualsi kontoga" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/fr.json b/homeassistant/components/rituals_perfume_genie/translations/fr.json new file mode 100644 index 00000000000..2a1fb9c8bb8 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/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": { + "email": "Email", + "password": "Mot de passe" + }, + "title": "Connectez-vous \u00e0 votre compte Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/it.json b/homeassistant/components/rituals_perfume_genie/translations/it.json new file mode 100644 index 00000000000..6dfb2230285 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "title": "Collegati al tuo account Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/nl.json b/homeassistant/components/rituals_perfume_genie/translations/nl.json new file mode 100644 index 00000000000..432079cac25 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Verbind met uw Rituals account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/no.json b/homeassistant/components/rituals_perfume_genie/translations/no.json new file mode 100644 index 00000000000..2ffc9bc91af --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "title": "Koble til Rituals-kontoen din" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/pl.json b/homeassistant/components/rituals_perfume_genie/translations/pl.json new file mode 100644 index 00000000000..9e8c9839cbe --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + }, + "title": "Po\u0142\u0105czenie z kontem Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/ru.json b/homeassistant/components/rituals_perfume_genie/translations/ru.json new file mode 100644 index 00000000000..afbf1da0e46 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/ru.json @@ -0,0 +1,21 @@ +{ + "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.", + "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": { + "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" + }, + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Rituals" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/zh-Hant.json b/homeassistant/components/rituals_perfume_genie/translations/zh-Hant.json new file mode 100644 index 00000000000..c91a500edd8 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "title": "\u9023\u7dda\u81f3 Rituals \u5e33\u865f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json index 595e2d4834a..68f895cb2b8 100644 --- a/homeassistant/components/rmvtransport/manifest.json +++ b/homeassistant/components/rmvtransport/manifest.json @@ -3,7 +3,7 @@ "name": "RMV", "documentation": "https://www.home-assistant.io/integrations/rmvtransport", "requirements": [ - "PyRMVtransport==0.2.10" + "PyRMVtransport==0.3.1" ], "codeowners": [ "@cgtobi" diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index eb053de8950..ad1ceea3d86 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -4,11 +4,14 @@ from datetime import timedelta import logging from RMVtransport import RMVtransport -from RMVtransport.rmvtransport import RMVtransportApiConnectionError +from RMVtransport.rmvtransport import ( + RMVtransportApiConnectionError, + RMVtransportDataError, +) import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_TIMEOUT, TIME_MINUTES from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -25,7 +28,6 @@ CONF_LINES = "lines" CONF_PRODUCTS = "products" CONF_TIME_OFFSET = "time_offset" CONF_MAX_JOURNEYS = "max_journeys" -CONF_TIMEOUT = "timeout" DEFAULT_NAME = "RMV Journey" @@ -230,7 +232,7 @@ class RMVDepartureData: max_journeys=50, ) - except RMVtransportApiConnectionError: + except (RMVtransportApiConnectionError, RMVtransportDataError): self.departures = [] _LOGGER.warning("Could not retrieve data from rmv.de") return diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index af2e0ee946f..a75fc813fb9 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -27,6 +27,7 @@ from .const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_SOFTWARE_VERSION, + ATTR_SUGGESTED_AREA, DOMAIN, ) @@ -161,4 +162,5 @@ class RokuEntity(CoordinatorEntity): ATTR_MANUFACTURER: self.coordinator.data.info.brand, ATTR_MODEL: self.coordinator.data.info.model_name, ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version, + ATTR_SUGGESTED_AREA: self.coordinator.data.info.device_location, } diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index f8e9034292c..b086d7a9311 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -109,7 +109,6 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): updates={CONF_HOST: discovery_info[CONF_HOST]}, ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": info["title"]}}) self.discovery_info.update({CONF_NAME: info["title"]}) @@ -126,7 +125,6 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": name}}) self.discovery_info.update({CONF_HOST: host, CONF_NAME: name}) @@ -146,7 +144,6 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: Optional[Dict] = None ) -> Dict[str, Any]: """Handle user-confirmation of discovered device.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if user_input is None: return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py index 4abbd9e109a..dc458c88cd0 100644 --- a/homeassistant/components/roku/const.py +++ b/homeassistant/components/roku/const.py @@ -7,6 +7,7 @@ ATTR_KEYWORD = "keyword" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_SUGGESTED_AREA = "suggested_area" # Default Values DEFAULT_PORT = 8060 diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index f1509edb6fb..10cef13cda0 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.6.0"], + "requirements": ["rokuecp==0.8.0"], "homekit": { "models": [ "3810X", diff --git a/homeassistant/components/roku/translations/ca.json b/homeassistant/components/roku/translations/ca.json index eb0564b5bde..b60b8f83eb9 100644 --- a/homeassistant/components/roku/translations/ca.json +++ b/homeassistant/components/roku/translations/ca.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "unknown": "Error inesperat" }, "error": { diff --git a/homeassistant/components/roku/translations/cs.json b/homeassistant/components/roku/translations/cs.json index 89ca523af47..6914a519285 100644 --- a/homeassistant/components/roku/translations/cs.json +++ b/homeassistant/components/roku/translations/cs.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "error": { diff --git a/homeassistant/components/roku/translations/de.json b/homeassistant/components/roku/translations/de.json index 4bfb3c7503d..152161cb27f 100644 --- a/homeassistant/components/roku/translations/de.json +++ b/homeassistant/components/roku/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Das Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/roku/translations/et.json b/homeassistant/components/roku/translations/et.json index 6727f539f57..17bce39f5df 100644 --- a/homeassistant/components/roku/translations/et.json +++ b/homeassistant/components/roku/translations/et.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", "unknown": "Tundmatu viga" }, "error": { diff --git a/homeassistant/components/roku/translations/fr.json b/homeassistant/components/roku/translations/fr.json index 7aba1ef0489..b3dc08a7dc8 100644 --- a/homeassistant/components/roku/translations/fr.json +++ b/homeassistant/components/roku/translations/fr.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "unknown": "Erreur inattendue" }, "error": { @@ -9,6 +10,14 @@ }, "flow_title": "Roku: {name}", "step": { + "discovery_confirm": { + "data": { + "one": "Vide", + "other": "Vide" + }, + "description": "Voulez-vous configurer {name} ?", + "title": "Roku" + }, "ssdp_confirm": { "data": { "one": "Vide", diff --git a/homeassistant/components/roku/translations/it.json b/homeassistant/components/roku/translations/it.json index 100d9992472..3c11aa4d8ae 100644 --- a/homeassistant/components/roku/translations/it.json +++ b/homeassistant/components/roku/translations/it.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "unknown": "Errore imprevisto" }, "error": { diff --git a/homeassistant/components/roku/translations/ko.json b/homeassistant/components/roku/translations/ko.json index 054c1674884..19f4c16785f 100644 --- a/homeassistant/components/roku/translations/ko.json +++ b/homeassistant/components/roku/translations/ko.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { diff --git a/homeassistant/components/roku/translations/nl.json b/homeassistant/components/roku/translations/nl.json index 6b0927178fe..d892d2c78d2 100644 --- a/homeassistant/components/roku/translations/nl.json +++ b/homeassistant/components/roku/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Roku-apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", "unknown": "Onverwachte fout" }, "error": { @@ -9,6 +10,14 @@ }, "flow_title": "Roku: {name}", "step": { + "discovery_confirm": { + "data": { + "one": "Een", + "other": "Ander" + }, + "description": "Wilt u {naam} instellen?", + "title": "Roku" + }, "ssdp_confirm": { "data": { "one": "Leeg", diff --git a/homeassistant/components/roku/translations/no.json b/homeassistant/components/roku/translations/no.json index dd4ce418141..e7dc663b8f8 100644 --- a/homeassistant/components/roku/translations/no.json +++ b/homeassistant/components/roku/translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/roku/translations/pl.json b/homeassistant/components/roku/translations/pl.json index 1d193acc0ff..1a570c64347 100644 --- a/homeassistant/components/roku/translations/pl.json +++ b/homeassistant/components/roku/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", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { diff --git a/homeassistant/components/roku/translations/ru.json b/homeassistant/components/roku/translations/ru.json index f7f36f41b27..c3ae135ed76 100644 --- a/homeassistant/components/roku/translations/ru.json +++ b/homeassistant/components/roku/translations/ru.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { diff --git a/homeassistant/components/roku/translations/sv.json b/homeassistant/components/roku/translations/sv.json index 6d6f9223466..524e7753548 100644 --- a/homeassistant/components/roku/translations/sv.json +++ b/homeassistant/components/roku/translations/sv.json @@ -2,6 +2,18 @@ "config": { "abort": { "unknown": "Ov\u00e4ntat fel" + }, + "flow_title": "Roku: {name}", + "step": { + "ssdp_confirm": { + "description": "Vill du konfigurera {name}?", + "title": "Roku" + }, + "user": { + "data": { + "host": "V\u00e4rd" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/roku/translations/zh-Hant.json b/homeassistant/components/roku/translations/zh-Hant.json index 4b0566d66b0..429c03a991e 100644 --- a/homeassistant/components/roku/translations/zh-Hant.json +++ b/homeassistant/components/roku/translations/zh-Hant.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 63deead7307..658c230c3a7 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -6,18 +6,9 @@ import async_timeout from roombapy import Roomba, RoombaConnectionError from homeassistant import exceptions -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD -from .const import ( - BLID, - COMPONENTS, - CONF_BLID, - CONF_CONTINUOUS, - CONF_DELAY, - CONF_NAME, - DOMAIN, - ROOMBA_SESSION, -) +from .const import BLID, COMPONENTS, CONF_BLID, CONF_CONTINUOUS, DOMAIN, ROOMBA_SESSION _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 4b0b76b44c9..787382ed8b5 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -9,15 +9,13 @@ import voluptuous as vol from homeassistant import config_entries, core from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import callback from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( CONF_BLID, CONF_CONTINUOUS, - CONF_DELAY, - CONF_NAME, DEFAULT_CONTINUOUS, DEFAULT_DELAY, ROOMBA_SESSION, @@ -93,7 +91,6 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = dhcp_discovery[IP_ADDRESS] self.blid = blid - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"host": self.host, "name": self.blid} return await self.async_step_user() @@ -135,7 +132,6 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } if self.host and self.host in self.discovered_robots: # From discovery - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { "host": self.host, "name": self.discovered_robots[self.host].robot_name, diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index 06684e63bdc..2ffb34eb7c8 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -3,8 +3,6 @@ DOMAIN = "roomba" COMPONENTS = ["sensor", "binary_sensor", "vacuum"] CONF_CERT = "certificate" CONF_CONTINUOUS = "continuous" -CONF_DELAY = "delay" -CONF_NAME = "name" CONF_BLID = "blid" DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_CONTINUOUS = True diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 512e27a758e..48e130df4f5 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -23,7 +23,7 @@ }, "link_manual": { "title": "Enter Password", - "description": "The password could not be retrivied from the device automatically. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "The password could not be retrieved from the device automatically. Please follow the steps outlined in the documentation at: {auth_help_url}", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index 8d449e18815..9c373d649aa 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -25,7 +25,7 @@ "data": { "password": "Password" }, - "description": "The password could not be retrivied from the device automatically. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "The password could not be retrieved from the device automatically. Please follow the steps outlined in the documentation at: {auth_help_url}", "title": "Enter Password" }, "manual": { diff --git a/homeassistant/components/roomba/translations/fr.json b/homeassistant/components/roomba/translations/fr.json index 8142d3acf13..b4bc615e4e3 100644 --- a/homeassistant/components/roomba/translations/fr.json +++ b/homeassistant/components/roomba/translations/fr.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "cannot_connect": "Echec de connection" + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "cannot_connect": "Echec de connection", + "not_irobot_device": "L'appareil d\u00e9couvert n'est pas un appareil iRobot" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer" }, + "flow_title": "iRobot {name} ( {host} )", "step": { "init": { "data": { diff --git a/homeassistant/components/roomba/translations/ko.json b/homeassistant/components/roomba/translations/ko.json index ebf9056c037..d9c661a20dd 100644 --- a/homeassistant/components/roomba/translations/ko.json +++ b/homeassistant/components/roomba/translations/ko.json @@ -1,9 +1,28 @@ { "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { + "init": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + } + }, + "link_manual": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + } + }, + "manual": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + } + }, "user": { "data": { "blid": "BLID", diff --git a/homeassistant/components/roomba/translations/nl.json b/homeassistant/components/roomba/translations/nl.json index f5268bdf799..0177adaea1f 100644 --- a/homeassistant/components/roomba/translations/nl.json +++ b/homeassistant/components/roomba/translations/nl.json @@ -1,9 +1,39 @@ { "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "not_irobot_device": "Het gevonden apparaat is geen iRobot-apparaat" + }, "error": { "cannot_connect": "Verbinding mislukt, probeer het opnieuw" }, + "flow_title": "iRobot {naam} ({host})", "step": { + "init": { + "data": { + "host": "Host" + }, + "description": "Kies een Roomba of Braava.", + "title": "Automatisch verbinding maken met het apparaat" + }, + "link": { + "description": "Houd de Home-knop op {naam} ingedrukt totdat het apparaat een geluid genereert (ongeveer twee seconden).", + "title": "Wachtwoord opvragen" + }, + "link_manual": { + "data": { + "password": "Wachtwoord" + }, + "description": "Het wachtwoord kon niet automatisch van het apparaat worden opgehaald. Volg de stappen zoals beschreven in de documentatie op: {auth_help_url}", + "title": "Voer wachtwoord in" + }, + "manual": { + "data": { + "blid": "BLID", + "host": "Host" + } + }, "user": { "data": { "blid": "BLID", diff --git a/homeassistant/components/roomba/translations/no.json b/homeassistant/components/roomba/translations/no.json index 2bfe9f774d1..67df735719c 100644 --- a/homeassistant/components/roomba/translations/no.json +++ b/homeassistant/components/roomba/translations/no.json @@ -25,7 +25,7 @@ "data": { "password": "Passord" }, - "description": "Passordet kunne ikke hentes automatisk fra enheten. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", + "description": "Passordet kan ikke hentes fra enheten automatisk. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", "title": "Skriv inn passord" }, "manual": { diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 8abcba189da..b2ae62ec250 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -175,7 +175,7 @@ class RoonDevice(MediaPlayerEntity): "name": self.name, "manufacturer": "RoonLabs", "model": dev_model, - "via_hub": (DOMAIN, self._server.roon_id), + "via_device": (DOMAIN, self._server.roon_id), } def update_data(self, player_data=None): diff --git a/homeassistant/components/roon/translations/ko.json b/homeassistant/components/roon/translations/ko.json index ae483b0b098..7c051d49dc7 100644 --- a/homeassistant/components/roon/translations/ko.json +++ b/homeassistant/components/roon/translations/ko.json @@ -17,7 +17,7 @@ "data": { "host": "\ud638\uc2a4\ud2b8" }, - "description": "Roon \uc11c\ubc84 Hostname \ub610\ub294 IP\ub97c \uc785\ub825\ud558\uc2ed\uc2dc\uc624." + "description": "Roon \uc11c\ubc84\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\uba85\uc774\ub098 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." } } } diff --git a/homeassistant/components/roon/translations/ru.json b/homeassistant/components/roon/translations/ru.json index 187151affe2..c01006d6269 100644 --- a/homeassistant/components/roon/translations/ru.json +++ b/homeassistant/components/roon/translations/ru.json @@ -5,7 +5,7 @@ }, "error": { "duplicate_entry": "\u042d\u0442\u043e\u0442 \u0445\u043e\u0441\u0442 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/rova/manifest.json b/homeassistant/components/rova/manifest.json index a4ba931da43..b3635b39f38 100644 --- a/homeassistant/components/rova/manifest.json +++ b/homeassistant/components/rova/manifest.json @@ -2,6 +2,6 @@ "domain": "rova", "name": "ROVA", "documentation": "https://www.home-assistant.io/integrations/rova", - "requirements": ["rova==0.1.0"], + "requirements": ["rova==0.2.1"], "codeowners": [] } diff --git a/homeassistant/components/rpi_gpio/binary_sensor.py b/homeassistant/components/rpi_gpio/binary_sensor.py index a2461f52db9..36d7ae50f32 100644 --- a/homeassistant/components/rpi_gpio/binary_sensor.py +++ b/homeassistant/components/rpi_gpio/binary_sensor.py @@ -32,7 +32,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Raspberry PI GPIO devices.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) pull_mode = config.get(CONF_PULL_MODE) diff --git a/homeassistant/components/rpi_gpio/cover.py b/homeassistant/components/rpi_gpio/cover.py index 032796fe55b..15eae3b4b07 100644 --- a/homeassistant/components/rpi_gpio/cover.py +++ b/homeassistant/components/rpi_gpio/cover.py @@ -5,13 +5,12 @@ import voluptuous as vol from homeassistant.components import rpi_gpio from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_COVERS, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from . import DOMAIN, PLATFORMS -CONF_COVERS = "covers" CONF_RELAY_PIN = "relay_pin" CONF_RELAY_TIME = "relay_time" CONF_STATE_PIN = "state_pin" @@ -49,7 +48,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the RPi cover platform.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) relay_time = config.get(CONF_RELAY_TIME) diff --git a/homeassistant/components/rpi_gpio/switch.py b/homeassistant/components/rpi_gpio/switch.py index d556d8f0354..3fba7b4b2cb 100644 --- a/homeassistant/components/rpi_gpio/switch.py +++ b/homeassistant/components/rpi_gpio/switch.py @@ -28,7 +28,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Raspberry PI GPIO devices.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) invert_logic = config.get(CONF_INVERT_LOGIC) diff --git a/homeassistant/components/rpi_power/translations/et.json b/homeassistant/components/rpi_power/translations/et.json index fdf32414ba7..ec8475d2dac 100644 --- a/homeassistant/components/rpi_power/translations/et.json +++ b/homeassistant/components/rpi_power/translations/et.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Ei leia selle komponendi jaoks vajalikku s\u00fcsteemiklassi. Veenduge, et teie kernel on v\u00e4rske ja riistvara on toetatud", + "no_devices_found": "Ei leia selle komponendi jaoks vajalikku s\u00fcsteemiklassi. Veendu, et kernel on v\u00e4rske ja riistvara on toetatud", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, "step": { diff --git a/homeassistant/components/rpi_power/translations/ko.json b/homeassistant/components/rpi_power/translations/ko.json index b9a9a1be643..445c0c34e68 100644 --- a/homeassistant/components/rpi_power/translations/ko.json +++ b/homeassistant/components/rpi_power/translations/ko.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "\uc774 \uad6c\uc131 \uc694\uc18c\uc5d0 \ud544\uc694\ud55c \uc2dc\uc2a4\ud15c \ud074\ub798\uc2a4\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucee4\ub110\uc774 \ucd5c\uc2e0\uc774\uace0 \ud558\ub4dc\uc6e8\uc5b4\uac00 \uc9c0\uc6d0\ub418\ub294\uc9c0 \ud655\uc778\ud558\uc2ed\uc2dc\uc624.", - "single_instance_allowed": "\uc774\ubbf8 \uc124\uc815\ub418\uc5b4 \uc788\uc74c. \ud558\ub098\uc758 \uc124\uc815\ub9cc \uac00\ub2a5\ud568." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/rpi_power/translations/nl.json b/homeassistant/components/rpi_power/translations/nl.json index 72f9ff82ba4..8c15279dca8 100644 --- a/homeassistant/components/rpi_power/translations/nl.json +++ b/homeassistant/components/rpi_power/translations/nl.json @@ -2,6 +2,11 @@ "config": { "abort": { "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je beginnen met instellen?" + } } }, "title": "Raspberry Pi Voeding Checker" diff --git a/homeassistant/components/rpi_rf/switch.py b/homeassistant/components/rpi_rf/switch.py index 78c2153a7b3..4ac7283b194 100644 --- a/homeassistant/components/rpi_rf/switch.py +++ b/homeassistant/components/rpi_rf/switch.py @@ -6,7 +6,12 @@ from threading import RLock import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_NAME, CONF_SWITCHES, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_NAME, + CONF_PROTOCOL, + CONF_SWITCHES, + EVENT_HOMEASSISTANT_STOP, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -14,7 +19,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_OFF = "code_off" CONF_CODE_ON = "code_on" CONF_GPIO = "gpio" -CONF_PROTOCOL = "protocol" CONF_PULSELENGTH = "pulselength" CONF_SIGNAL_REPETITIONS = "signal_repetitions" diff --git a/homeassistant/components/ruckus_unleashed/translations/ko.json b/homeassistant/components/ruckus_unleashed/translations/ko.json new file mode 100644 index 00000000000..9ba063c37dd --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ruckus_unleashed/translations/nl.json b/homeassistant/components/ruckus_unleashed/translations/nl.json index 7482a0bbe7c..8ad15260b0d 100644 --- a/homeassistant/components/ruckus_unleashed/translations/nl.json +++ b/homeassistant/components/ruckus_unleashed/translations/nl.json @@ -4,12 +4,16 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" }, "step": { "user": { "data": { - "password": "Wachtwoord" + "host": "Host", + "password": "Wachtwoord", + "username": "Gebruikersnaam" } } } diff --git a/homeassistant/components/ruckus_unleashed/translations/ru.json b/homeassistant/components/ruckus_unleashed/translations/ru.json index 6f71ee41376..9e0db9fcf94 100644 --- a/homeassistant/components/ruckus_unleashed/translations/ru.json +++ b/homeassistant/components/ruckus_unleashed/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 7a7fa26f922..73d0d0b5e93 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -51,8 +51,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - def __init__(self): """Initialize flow.""" self._host = None diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 5584d2dd452..08dc4d0c049 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/samsungtv", "requirements": [ "samsungctl[websocket]==0.7.1", - "samsungtvws==1.4.0" + "samsungtvws==1.6.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/translations/ko.json b/homeassistant/components/samsungtv/translations/ko.json index 1c7e0b29808..14e35a7ff2e 100644 --- a/homeassistant/components/samsungtv/translations/ko.json +++ b/homeassistant/components/samsungtv/translations/ko.json @@ -1,9 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 \uc0bc\uc131 TV \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "already_in_progress": "\uc0bc\uc131 TV \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "auth_missing": "Home Assistant \uac00 \ud574\ub2f9 \uc0bc\uc131 TV \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc788\ub294 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. TV \uc124\uc815\uc744 \ud655\uc778\ud558\uc5ec Home Assistant \ub97c \uc2b9\uc778\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "not_supported": "\uc774 \uc0bc\uc131 TV \ubaa8\ub378\uc740 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "flow_title": "\uc0bc\uc131 TV: {model}", diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index 29fa11e9367..9d07460379c 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -1,53 +1,83 @@ # Describes the format for available scene services turn_on: + name: Activate description: Activate a scene. + target: fields: transition: + name: Transition description: Transition duration in seconds it takes to bring devices to the state defined in the scene. example: 2.5 - entity_id: - description: Name(s) of scenes to turn on - example: "scene.romantic" + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider reload: - description: Reload the scene configuration + name: Reload + description: Reload the scene configuration. apply: - description: - Activate a scene. Takes same data as the entities field from a single scene - in the config. + name: Apply + description: Activate a scene with configuration. fields: - transition: - description: - Transition duration in seconds it takes to bring devices to the state - defined in the scene. - example: 2.5 entities: + name: Entities state description: The entities and the state that they need to be. + required: true example: light.kitchen: "on" light.ceiling: state: "on" brightness: 80 + selector: + object: + transition: + name: Transition + description: + Transition duration in seconds it takes to bring devices to the state + defined in the scene. + example: 2.5 + selector: + number: + min: 0 + max: 300 + step: 1 + unit_of_measurement: seconds + mode: slider create: + name: Create description: Creates a new scene. fields: scene_id: + name: Scene entity ID description: The entity_id of the new scene. + required: true example: all_lights + selector: + text: entities: + name: Entities state description: The entities to control with the scene. example: light.tv_back_light: "on" light.ceiling: state: "on" brightness: 200 + selector: + object: snapshot_entities: + name: Snapshot entities description: The entities of which a snapshot is to be taken example: - light.ceiling - light.kitchen + selector: + object: diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index dcb3a8fdff0..daa5a269dcf 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -2,7 +2,7 @@ "domain": "scrape", "name": "Scrape", "documentation": "https://www.home-assistant.io/integrations/scrape", - "requirements": ["beautifulsoup4==4.9.1"], + "requirements": ["beautifulsoup4==4.9.3"], "after_dependencies": ["rest"], "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index eab30e01ee2..5de3cb8264f 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -181,7 +181,10 @@ async def async_setup(hass, config): return await asyncio.wait( - [script_entity.async_turn_off() for script_entity in script_entities] + [ + asyncio.create_task(script_entity.async_turn_off()) + for script_entity in script_entities + ] ) async def toggle_service(service): diff --git a/homeassistant/components/script/services.yaml b/homeassistant/components/script/services.yaml index 1347f760b54..5af81734a9e 100644 --- a/homeassistant/components/script/services.yaml +++ b/homeassistant/components/script/services.yaml @@ -5,21 +5,12 @@ reload: turn_on: description: Turn on script - fields: - entity_id: - description: Name(s) of script to be turned on. - example: "script.arrive_home" + target: turn_off: description: Turn off script - fields: - entity_id: - description: Name(s) of script to be turned off. - example: "script.arrive_home" + target: toggle: description: Toggle script - fields: - entity_id: - description: Name(s) of script to be toggled. - example: "script.arrive_home" + target: diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 114c64c390b..2d6c0c41e5b 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -96,7 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): password = entry_data[CONF_PASSWORD] timeout = entry_data[CONF_TIMEOUT] - gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + client_session = async_get_clientsession(hass) + + gateway = ASyncSenseable( + api_timeout=timeout, wss_timeout=timeout, client_session=client_session + ) gateway.rate_limit = ACTIVE_UPDATE_RATE try: diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index f8b8ede6a4c..866c1683b1e 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant import config_entries, core from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, SENSE_TIMEOUT_EXCEPTIONS from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import @@ -27,8 +28,11 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ timeout = data[CONF_TIMEOUT] + client_session = async_get_clientsession(hass) - gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway = ASyncSenseable( + api_timeout=timeout, wss_timeout=timeout, client_session=client_session + ) gateway.rate_limit = ACTIVE_UPDATE_RATE await gateway.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]) diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index bd132f1f983..57028ccb395 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.8.1"], + "requirements": ["sense_energy==0.9.0"], "codeowners": ["@kbickar"], "config_flow": true, "dhcp": [{"hostname":"sense-*","macaddress":"009D6B*"}, {"hostname":"sense-*","macaddress":"DCEFCA*"}] diff --git a/homeassistant/components/sense/translations/ko.json b/homeassistant/components/sense/translations/ko.json index 26545db739a..517ad7af17d 100644 --- a/homeassistant/components/sense/translations/ko.json +++ b/homeassistant/components/sense/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/sense/translations/ru.json b/homeassistant/components/sense/translations/ru.json index 74be3049a75..0bb299e2208 100644 --- a/homeassistant/components/sense/translations/ru.json +++ b/homeassistant/components/sense/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index da5294b9258..d0592493f15 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,6 +3,6 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==0.19.5"], + "requirements": ["sentry-sdk==0.20.3"], "codeowners": ["@dcramer", "@frenck"] } diff --git a/homeassistant/components/sentry/translations/ko.json b/homeassistant/components/sentry/translations/ko.json index 963695dabfd..6c00ffea2ef 100644 --- a/homeassistant/components/sentry/translations/ko.json +++ b/homeassistant/components/sentry/translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, "error": { "bad_dsn": "DSN \uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/sentry/translations/nl.json b/homeassistant/components/sentry/translations/nl.json index 37437dfe836..64b7f1b73f7 100644 --- a/homeassistant/components/sentry/translations/nl.json +++ b/homeassistant/components/sentry/translations/nl.json @@ -9,6 +9,9 @@ }, "step": { "user": { + "data": { + "dsn": "DSN" + }, "description": "Voer uw Sentry DSN in", "title": "Sentry" } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 01e0275feeb..4f9f6514531 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -2,6 +2,6 @@ "domain": "seven_segments", "name": "Seven Segments OCR", "documentation": "https://www.home-assistant.io/integrations/seven_segments", - "requirements": ["pillow==8.1.0"], + "requirements": ["pillow==8.1.1"], "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 94efe9b98c7..fa94ca4e384 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -244,7 +244,7 @@ class SeventeenTrackPackageSensor(Entity): async def _remove(self, *_): """Remove entity itself.""" - await self.async_remove() + await self.async_remove(force_remove=True) reg = await self.hass.helpers.entity_registry.async_get_registry() entity_id = reg.async_get_entity_id( diff --git a/homeassistant/components/sharkiq/translations/it.json b/homeassistant/components/sharkiq/translations/it.json index 4f7940cb5fc..cfba2066bfc 100644 --- a/homeassistant/components/sharkiq/translations/it.json +++ b/homeassistant/components/sharkiq/translations/it.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "cannot_connect": "Impossibile connettersi", - "reauth_successful": "La riautenticazione ha avuto successo", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" }, "error": { diff --git a/homeassistant/components/sharkiq/translations/ko.json b/homeassistant/components/sharkiq/translations/ko.json index 92649031534..04f400212f1 100644 --- a/homeassistant/components/sharkiq/translations/ko.json +++ b/homeassistant/components/sharkiq/translations/ko.json @@ -1,26 +1,27 @@ { "config": { "abort": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "reauth_successful": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc131\uacf5\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328", - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "reauth": { "data": { - "password": "\uc554\ud638", - "username": "\uc0ac\uc6a9\uc790\uba85" + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" } }, "user": { "data": { - "password": "\uc554\ud638", - "username": "\uc0ac\uc6a9\uc790\uba85" + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" } } } diff --git a/homeassistant/components/sharkiq/translations/nl.json b/homeassistant/components/sharkiq/translations/nl.json index 96c10f3e2f0..3acfdbdf074 100644 --- a/homeassistant/components/sharkiq/translations/nl.json +++ b/homeassistant/components/sharkiq/translations/nl.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Account is al geconfigureerd", "cannot_connect": "Kan geen verbinding maken", + "reauth_successful": "Herauthenticatie was succesvol", "unknown": "Onverwachte fout" }, "error": { diff --git a/homeassistant/components/sharkiq/translations/ru.json b/homeassistant/components/sharkiq/translations/ru.json index 80af08a8958..60ce7d454d6 100644 --- a/homeassistant/components/sharkiq/translations/ru.json +++ b/homeassistant/components/sharkiq/translations/ru.json @@ -8,7 +8,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index d2df03b44a5..ccb52127525 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -2,7 +2,6 @@ import asyncio from datetime import timedelta import logging -from socket import gethostbyname import aioshelly import async_timeout @@ -17,12 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - device_registry, - singleton, - update_coordinator, -) +from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, @@ -32,36 +26,23 @@ from .const import ( BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, COAP, DATA_CONFIG_ENTRY, + DEVICE, DOMAIN, EVENT_SHELLY_CLICK, INPUTS_EVENTS_DICT, - POLLING_TIMEOUT_MULTIPLIER, + POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, ) -from .utils import get_device_name +from .utils import get_coap_context, get_device_name, get_device_sleep_period PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"] +SLEEPING_PLATFORMS = ["binary_sensor", "sensor"] _LOGGER = logging.getLogger(__name__) -@singleton.singleton("shelly_coap") -async def get_coap_context(hass): - """Get CoAP context to be used in all Shelly devices.""" - context = aioshelly.COAP() - await context.initialize() - - @callback - def shutdown_listener(ev): - context.close() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) - - return context - - async def async_setup(hass: HomeAssistant, config: dict): """Set up the Shelly component.""" hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} @@ -70,12 +51,13 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Shelly from a config entry.""" + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + temperature_unit = "C" if hass.config.units.is_metric else "F" - ip_address = await hass.async_add_executor_job(gethostbyname, entry.data[CONF_HOST]) - options = aioshelly.ConnectionOptions( - ip_address, + entry.data[CONF_HOST], entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), temperature_unit, @@ -83,33 +65,80 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): coap_context = await get_coap_context(hass) - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await aioshelly.Device.create( - aiohttp_client.async_get_clientsession(hass), - coap_context, - options, - ) - except (asyncio.TimeoutError, OSError) as err: - raise ConfigEntryNotReady from err + device = await aioshelly.Device.create( + aiohttp_client.async_get_clientsession(hass), + coap_context, + options, + False, + ) - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + dev_reg = await device_registry.async_get_registry(hass) + identifier = (DOMAIN, entry.unique_id) + device_entry = dev_reg.async_get_device(identifiers={identifier}, connections=set()) + + sleep_period = entry.data.get("sleep_period") + + @callback + def _async_device_online(_): + _LOGGER.debug("Device %s is online, resuming setup", entry.title) + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + + if sleep_period is None: + data = {**entry.data} + data["sleep_period"] = get_device_sleep_period(device.settings) + data["model"] = device.settings["device"]["type"] + hass.config_entries.async_update_entry(entry, data=data) + + hass.async_create_task(async_device_setup(hass, entry, device)) + + if sleep_period == 0: + # Not a sleeping device, finish setup + _LOGGER.debug("Setting up online device %s", entry.title) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + await device.initialize(True) + except (asyncio.TimeoutError, OSError) as err: + raise ConfigEntryNotReady from err + + await async_device_setup(hass, entry, device) + elif sleep_period is None or device_entry is None: + # Need to get sleep info or first time sleeping device setup, wait for device + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device + _LOGGER.debug( + "Setup for device %s will resume when device is online", entry.title + ) + device.subscribe_updates(_async_device_online) + await device.coap_request("s") + else: + # Restore sensors for sleeping device + _LOGGER.debug("Setting up offline device %s", entry.title) + await async_device_setup(hass, entry, device) + + return True + + +async def async_device_setup( + hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device +): + """Set up a device that is online.""" + device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ COAP ] = ShellyDeviceWrapper(hass, entry, device) - await coap_wrapper.async_setup() + await device_wrapper.async_setup() - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - REST - ] = ShellyDeviceRestWrapper(hass, device) + platforms = SLEEPING_PLATFORMS - for component in PLATFORMS: + if not entry.data.get("sleep_period"): + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + REST + ] = ShellyDeviceRestWrapper(hass, device) + platforms = PLATFORMS + + for component in platforms: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - return True - class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Wrapper for a Shelly device with Home Assistant specific functions.""" @@ -117,43 +146,40 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): def __init__(self, hass, entry, device: aioshelly.Device): """Initialize the Shelly device wrapper.""" self.device_id = None - sleep_mode = device.settings.get("sleep_mode") + sleep_period = entry.data["sleep_period"] - if sleep_mode: - sleep_period = sleep_mode["period"] - if sleep_mode["unit"] == "h": - sleep_period *= 60 # hours to minutes - - update_interval = ( - SLEEP_PERIOD_MULTIPLIER * sleep_period * 60 - ) # minutes to seconds + if sleep_period: + update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] ) + device_name = get_device_name(device) if device.initialized else entry.title super().__init__( hass, _LOGGER, - name=get_device_name(device), + name=device_name, update_interval=timedelta(seconds=update_interval), ) self.hass = hass self.entry = entry self.device = device - self.device.subscribe_updates(self.async_set_updated_data) - - self._async_remove_input_events_handler = self.async_add_listener( - self._async_input_events_handler + self._async_remove_device_updates_handler = self.async_add_listener( + self._async_device_updates_handler ) - self._last_input_events_count = dict() + self._last_input_events_count = {} hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback - def _async_input_events_handler(self): - """Handle device input events.""" + def _async_device_updates_handler(self): + """Handle device updates.""" + if not self.device.initialized: + return + + # Check for input events for block in self.device.blocks: if ( "inputEvent" not in block.sensor_ids @@ -192,13 +218,13 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self): """Fetch data.""" + if self.entry.data.get("sleep_period"): + # Sleeping device, no point polling it, just mark it unavailable + raise update_coordinator.UpdateFailed("Sleeping device did not update") _LOGGER.debug("Polling Shelly Device - %s", self.name) try: - async with async_timeout.timeout( - POLLING_TIMEOUT_MULTIPLIER - * self.device.settings["coiot"]["update_period"] - ): + async with async_timeout.timeout(POLLING_TIMEOUT_SEC): return await self.device.update() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @@ -206,18 +232,17 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): @property def model(self): """Model of the device.""" - return self.device.settings["device"]["type"] + return self.entry.data["model"] @property def mac(self): """Mac address of the device.""" - return self.device.settings["device"]["mac"] + return self.entry.unique_id async def async_setup(self): """Set up the wrapper.""" - dev_reg = await device_registry.async_get_registry(self.hass) - model_type = self.device.settings["device"]["type"] + sw_version = self.device.settings["fw"] if self.device.initialized else "" entry = dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, name=self.name, @@ -225,15 +250,16 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): # This is duplicate but otherwise via_device can't work identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=aioshelly.MODEL_NAMES.get(model_type, model_type), - sw_version=self.device.settings["fw"], + model=aioshelly.MODEL_NAMES.get(self.model, self.model), + sw_version=sw_version, ) self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) def shutdown(self): """Shutdown the wrapper.""" self.device.shutdown() - self._async_remove_input_events_handler() + self._async_remove_device_updates_handler() @callback def _handle_ha_stop(self, _): @@ -282,11 +308,23 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" + device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) + if device is not None: + # If device is present, device wrapper is not setup yet + device.shutdown() + return True + + platforms = SLEEPING_PLATFORMS + + if not entry.data.get("sleep_period"): + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None + platforms = PLATFORMS + unload_ok = all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS + for component in platforms ] ) ) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index d53f089054a..18220fc9e3a 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SMOKE, DEVICE_CLASS_VIBRATION, + STATE_ON, BinarySensorEntity, ) @@ -17,6 +18,7 @@ from .entity import ( RestAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, ) @@ -71,6 +73,11 @@ SENSORS = { default_enabled=False, removal_condition=is_momentary_input, ), + ("sensor", "extInput"): BlockAttributeDescription( + name="External Input", + device_class=DEVICE_CLASS_POWER, + default_enabled=False, + ), ("sensor", "motion"): BlockAttributeDescription( name="Motion", device_class=DEVICE_CLASS_MOTION ), @@ -84,7 +91,7 @@ REST_SENSORS = { default_enabled=False, ), "fwupdate": RestAttributeDescription( - name="Firmware update", + name="Firmware Update", icon="mdi:update", value=lambda status, _: status["update"]["has_update"], default_enabled=False, @@ -98,13 +105,25 @@ REST_SENSORS = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" - await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor - ) - - await async_setup_entry_rest( - hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestBinarySensor - ) + if config_entry.data["sleep_period"]: + await async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + SENSORS, + ShellySleepingBinarySensor, + ) + else: + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor + ) + await async_setup_entry_rest( + hass, + config_entry, + async_add_entities, + REST_SENSORS, + ShellyRestBinarySensor, + ) class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): @@ -123,3 +142,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): def is_on(self): """Return true if REST sensor state is on.""" return bool(self.attribute_value) + + +class ShellySleepingBinarySensor( + ShellySleepingBlockAttributeEntity, BinarySensorEntity +): + """Represent a shelly sleeping binary sensor.""" + + @property + def is_on(self): + """Return true if sensor state is on.""" + if self.block is not None: + return bool(self.attribute_value) + + return self.last_state == STATE_ON diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index b47c76cbb7a..dfb078ee9c7 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Shelly integration.""" import asyncio import logging -from socket import gethostbyname import aiohttp import aioshelly @@ -17,9 +16,9 @@ from homeassistant.const import ( ) from homeassistant.helpers import aiohttp_client -from . import get_coap_context from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC from .const import DOMAIN # pylint:disable=unused-import +from .utils import get_coap_context, get_device_sleep_period _LOGGER = logging.getLogger(__name__) @@ -33,10 +32,9 @@ async def validate_input(hass: core.HomeAssistant, host, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - ip_address = await hass.async_add_executor_job(gethostbyname, host) options = aioshelly.ConnectionOptions( - ip_address, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) + host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) ) coap_context = await get_coap_context(hass) @@ -53,6 +51,8 @@ async def validate_input(hass: core.HomeAssistant, host, data): return { "title": device.settings["name"], "hostname": device.settings["device"]["hostname"], + "sleep_period": get_device_sleep_period(device.settings), + "model": device.settings["device"]["type"], } @@ -63,6 +63,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH host = None info = None + device_info = None async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -95,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return self.async_create_entry( title=device_info["title"] or device_info["hostname"], - data=user_input, + data={ + **user_input, + "sleep_period": device_info["sleep_period"], + "model": device_info["model"], + }, ) return self.async_show_form( @@ -121,7 +126,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return self.async_create_entry( title=device_info["title"] or device_info["hostname"], - data={**user_input, CONF_HOST: self.host}, + data={ + **user_input, + CONF_HOST: self.host, + "sleep_period": device_info["sleep_period"], + "model": device_info["model"], + }, ) else: user_input = {} @@ -149,7 +159,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: zeroconf_info["host"]}) self.host = zeroconf_info["host"] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + + if not info["auth"] and info.get("sleep_mode", False): + try: + self.device_info = await validate_input(self.hass, self.host, {}) + except HTTP_CONNECT_ERRORS: + return self.async_abort(reason="cannot_connect") + self.context["title_placeholders"] = { "name": zeroconf_info.get("name", "").split(".")[0] } @@ -162,17 +178,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self.info["auth"]: return await self.async_step_credentials() + if self.device_info: + return self.async_create_entry( + title=self.device_info["title"] or self.device_info["hostname"], + data={ + "host": self.host, + "sleep_period": self.device_info["sleep_period"], + "model": self.device_info["model"], + }, + ) + try: device_info = await validate_input(self.hass, self.host, {}) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" + except aioshelly.AuthRequired: + return await self.async_step_credentials() except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( title=device_info["title"] or device_info["hostname"], - data={"host": self.host}, + data={ + "host": self.host, + "sleep_period": device_info["sleep_period"], + "model": device_info["model"], + }, ) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a5922d0b9c0..4fda656e7b4 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -2,11 +2,12 @@ COAP = "coap" DATA_CONFIG_ENTRY = "config_entry" +DEVICE = "device" DOMAIN = "shelly" REST = "rest" -# Used to calculate the timeout in "_async_update_data" used for polling data from devices. -POLLING_TIMEOUT_MULTIPLIER = 1.2 +# Used in "_async_update_data" as timeout for polling data from devices. +POLLING_TIMEOUT_SEC = 18 # Refresh interval for REST sensors REST_SENSORS_UPDATE_INTERVAL = 60 @@ -73,5 +74,5 @@ INPUTS_EVENTS_SUBTYPES = { # Kelvin value for colorTemp KELVIN_MAX_VALUE = 6500 -KELVIN_MIN_VALUE = 2700 -KELVIN_MIN_VALUE_SHBLB_1 = 3000 +KELVIN_MIN_VALUE_WHITE = 2700 +KELVIN_MIN_VALUE_COLOR = 3000 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 6caa7d5132c..0438e5fe6b7 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -3,6 +3,7 @@ from aioshelly import Block from homeassistant.components.cover import ( ATTR_POSITION, + DEVICE_CLASS_SHUTTER, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, @@ -75,6 +76,11 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): """Flag supported features.""" return self._supported_features + @property + def device_class(self) -> str: + """Return the class of the device.""" + return DEVICE_CLASS_SHUTTER + async def async_close_cover(self, **kwargs): """Close cover.""" self.control_result = await self.block.set_state(go="close") diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index f6cdfaee19f..9d4851c92a4 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -93,17 +93,15 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" config = TRIGGER_SCHEMA(config) - event_config = event_trigger.TRIGGER_SCHEMA( - { - event_trigger.CONF_PLATFORM: CONF_EVENT, - event_trigger.CONF_EVENT_TYPE: EVENT_SHELLY_CLICK, - event_trigger.CONF_EVENT_DATA: { - ATTR_DEVICE_ID: config[CONF_DEVICE_ID], - ATTR_CHANNEL: INPUTS_EVENTS_SUBTYPES[config[CONF_SUBTYPE]], - ATTR_CLICK_TYPE: config[CONF_TYPE], - }, - } - ) + event_config = { + event_trigger.CONF_PLATFORM: CONF_EVENT, + event_trigger.CONF_EVENT_TYPE: EVENT_SHELLY_CLICK, + event_trigger.CONF_EVENT_DATA: { + ATTR_DEVICE_ID: config[CONF_DEVICE_ID], + ATTR_CHANNEL: INPUTS_EVENTS_SUBTYPES[config[CONF_SUBTYPE]], + ATTR_CLICK_TYPE: config[CONF_TYPE], + }, + } event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index b4df2d486f8..71ab4703c79 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -1,24 +1,49 @@ """Shelly entity helper.""" from dataclasses import dataclass +import logging from typing import Any, Callable, Optional, Union import aioshelly +from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback -from homeassistant.helpers import device_registry, entity, update_coordinator +from homeassistant.helpers import ( + device_registry, + entity, + entity_registry, + update_coordinator, +) +from homeassistant.helpers.restore_state import RestoreEntity from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .utils import async_remove_shelly_entity, get_entity_name +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, sensors, sensor_class ): - """Set up entities for block attributes.""" + """Set up entities for attributes.""" wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id ][COAP] + + if wrapper.device.initialized: + await async_setup_block_attribute_entities( + hass, async_add_entities, wrapper, sensors, sensor_class + ) + else: + await async_restore_block_attribute_entities( + hass, config_entry, async_add_entities, wrapper, sensor_class + ) + + +async def async_setup_block_attribute_entities( + hass, async_add_entities, wrapper, sensors, sensor_class +): + """Set up entities for block attributes.""" blocks = [] for block in wrapper.device.blocks: @@ -36,9 +61,7 @@ async def async_setup_entry_attribute_entities( wrapper.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = sensor_class( - wrapper, block, sensor_id, description - ).unique_id + unique_id = f"{wrapper.mac}-{block.description}-{sensor_id}" await async_remove_shelly_entity(hass, domain, unique_id) else: blocks.append((block, sensor_id, description)) @@ -54,6 +77,39 @@ async def async_setup_entry_attribute_entities( ) +async def async_restore_block_attribute_entities( + hass, config_entry, async_add_entities, wrapper, sensor_class +): + """Restore block attributes entities.""" + entities = [] + + ent_reg = await entity_registry.async_get_registry(hass) + entries = entity_registry.async_entries_for_config_entry( + ent_reg, config_entry.entry_id + ) + + domain = sensor_class.__module__.split(".")[-1] + + for entry in entries: + if entry.domain != domain: + continue + + attribute = entry.unique_id.split("-")[-1] + description = BlockAttributeDescription( + name="", + icon=entry.original_icon, + unit=entry.unit_of_measurement, + device_class=entry.device_class, + ) + + entities.append(sensor_class(wrapper, None, attribute, description, entry)) + + if not entities: + return + + async_add_entities(entities) + + async def async_setup_entry_rest( hass, config_entry, async_add_entities, sensors, sensor_class ): @@ -163,7 +219,7 @@ class ShellyBlockEntity(entity.Entity): class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): - """Switch that controls a relay block on Shelly devices.""" + """Helper class to represent a block attribute.""" def __init__( self, @@ -176,12 +232,11 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): super().__init__(wrapper, block) self.attribute = attribute self.description = description - self.info = block.info(attribute) unit = self.description.unit if callable(unit): - unit = unit(self.info) + unit = unit(block.info(attribute)) self._unit = unit self._unique_id = f"{super().unique_id}-{self.attribute}" @@ -320,3 +375,67 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return None return self.description.device_state_attributes(self.wrapper.device.status) + + +class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity): + """Represent a shelly sleeping block attribute entity.""" + + # pylint: disable=super-init-not-called + def __init__( + self, + wrapper: ShellyDeviceWrapper, + block: aioshelly.Block, + attribute: str, + description: BlockAttributeDescription, + entry: Optional[ConfigEntry] = None, + ) -> None: + """Initialize the sleeping sensor.""" + self.last_state = None + self.wrapper = wrapper + self.attribute = attribute + self.block = block + self.description = description + self._unit = self.description.unit + + if block is not None: + if callable(self._unit): + self._unit = self._unit(block.info(attribute)) + + self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}" + self._name = get_entity_name( + self.wrapper.device, block, self.description.name + ) + else: + self._unique_id = entry.unique_id + self._name = entry.original_name + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + + if last_state is not None: + self.last_state = last_state.state + + @callback + def _update_callback(self): + """Handle device update.""" + if self.block is not None or not self.wrapper.device.initialized: + super()._update_callback() + return + + _, entity_block, entity_sensor = self.unique_id.split("-") + + for block in self.wrapper.device.blocks: + if block.description != entity_block: + continue + + for sensor_id in block.sensor_ids: + if sensor_id != entity_sensor: + continue + + self.block = block + _LOGGER.debug("Entity %s attached to block", self.name) + super()._update_callback() + return diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 5422f3fff05..0379bfec1cf 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -28,20 +28,13 @@ from .const import ( DATA_CONFIG_ENTRY, DOMAIN, KELVIN_MAX_VALUE, - KELVIN_MIN_VALUE, - KELVIN_MIN_VALUE_SHBLB_1, + KELVIN_MIN_VALUE_COLOR, + KELVIN_MIN_VALUE_WHITE, ) from .entity import ShellyBlockEntity from .utils import async_remove_shelly_entity -def min_kelvin(model: str): - """Kelvin (min) for colorTemp.""" - if model in ["SHBLB-1"]: - return KELVIN_MIN_VALUE_SHBLB_1 - return KELVIN_MIN_VALUE - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up lights for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] @@ -76,6 +69,8 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.control_result = None self.mode_result = None self._supported_features = 0 + self._min_kelvin = KELVIN_MIN_VALUE_WHITE + self._max_kelvin = KELVIN_MAX_VALUE if hasattr(block, "brightness") or hasattr(block, "gain"): self._supported_features |= SUPPORT_BRIGHTNESS @@ -85,6 +80,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self._supported_features |= SUPPORT_WHITE_VALUE if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"): self._supported_features |= SUPPORT_COLOR + self._min_kelvin = KELVIN_MIN_VALUE_COLOR @property def supported_features(self) -> int: @@ -118,7 +114,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return "white" @property - def brightness(self) -> Optional[int]: + def brightness(self) -> int: """Brightness of light.""" if self.mode == "color": if self.control_result: @@ -133,7 +129,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return int(brightness / 100 * 255) @property - def white_value(self) -> Optional[int]: + def white_value(self) -> int: """White value of light.""" if self.control_result: white = self.control_result["white"] @@ -142,7 +138,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return int(white) @property - def hs_color(self) -> Optional[Tuple[float, float]]: + def hs_color(self) -> Tuple[float, float]: """Return the hue and saturation color value of light.""" if self.mode == "white": return color_RGB_to_hs(255, 255, 255) @@ -158,7 +154,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return color_RGB_to_hs(red, green, blue) @property - def color_temp(self) -> Optional[float]: + def color_temp(self) -> Optional[int]: """Return the CT color value in mireds.""" if self.mode == "color": return None @@ -168,22 +164,19 @@ class ShellyLight(ShellyBlockEntity, LightEntity): else: color_temp = self.block.colorTemp - # If you set DUO to max mireds in Shelly app, 2700K, - # It reports 0 temp - if color_temp == 0: - return min_kelvin(self.wrapper.model) + color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp)) return int(color_temperature_kelvin_to_mired(color_temp)) @property - def min_mireds(self) -> Optional[float]: + def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" - return color_temperature_kelvin_to_mired(KELVIN_MAX_VALUE) + return int(color_temperature_kelvin_to_mired(self._max_kelvin)) @property - def max_mireds(self) -> Optional[float]: + def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" - return color_temperature_kelvin_to_mired(min_kelvin(self.wrapper.model)) + return int(color_temperature_kelvin_to_mired(self._min_kelvin)) async def async_turn_on(self, **kwargs) -> None: """Turn on light.""" @@ -192,6 +185,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.async_write_ha_state() return + set_mode = None params = {"turn": "on"} if ATTR_BRIGHTNESS in kwargs: tmp_brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100) @@ -201,27 +195,26 @@ class ShellyLight(ShellyBlockEntity, LightEntity): params["brightness"] = tmp_brightness if ATTR_COLOR_TEMP in kwargs: color_temp = color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - color_temp = min( - KELVIN_MAX_VALUE, max(min_kelvin(self.wrapper.model), color_temp) - ) + color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp)) # Color temperature change - used only in white mode, switch device mode to white - if self.mode == "color": - self.mode_result = await self.wrapper.device.switch_light_mode("white") - params["red"] = params["green"] = params["blue"] = 255 + set_mode = "white" + params["red"] = params["green"] = params["blue"] = 255 params["temp"] = int(color_temp) - elif ATTR_HS_COLOR in kwargs: + if ATTR_HS_COLOR in kwargs: red, green, blue = color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) # Color channels change - used only in color mode, switch device mode to color - if self.mode == "white": - self.mode_result = await self.wrapper.device.switch_light_mode("color") + set_mode = "color" params["red"] = red params["green"] = green params["blue"] = blue - elif ATTR_WHITE_VALUE in kwargs: + if ATTR_WHITE_VALUE in kwargs: # White channel change - used only in color mode, switch device mode device to color - if self.mode == "white": - self.mode_result = await self.wrapper.device.switch_light_mode("color") + set_mode = "color" params["white"] = int(kwargs[ATTR_WHITE_VALUE]) + + if set_mode and self.mode != set_mode: + self.mode_result = await self.wrapper.device.switch_light_mode(set_mode) + self.control_result = await self.block.set_state(**params) self.async_write_ha_state() diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b3511d4f6b0..a757947c5cf 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==0.5.1.beta0"], + "requirements": ["aioshelly==0.6.1"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }], "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"] } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b92b90c1b46..472f3be4dae 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -18,6 +18,7 @@ from .entity import ( RestAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, ) @@ -132,7 +133,7 @@ SENSORS = { available=lambda block: block.sensorOp == "normal", ), ("sensor", "extTemp"): BlockAttributeDescription( - name="Temperature", + name="External Temperature", unit=temperature_unit, value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, @@ -148,9 +149,13 @@ SENSORS = { unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, ), - ("sensor", "tilt"): BlockAttributeDescription(name="Tilt", unit=DEGREE), + ("sensor", "tilt"): BlockAttributeDescription( + name="Tilt", + unit=DEGREE, + icon="mdi:angle-acute", + ), ("relay", "totalWorkTime"): BlockAttributeDescription( - name="Lamp life", + name="Lamp Life", unit=PERCENTAGE, icon="mdi:progress-wrench", value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), @@ -185,12 +190,17 @@ REST_SENSORS = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" - await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellySensor - ) - await async_setup_entry_rest( - hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor - ) + if config_entry.data["sleep_period"]: + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor + ) + else: + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellySensor + ) + await async_setup_entry_rest( + hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor + ) class ShellySensor(ShellyBlockAttributeEntity): @@ -209,3 +219,15 @@ class ShellyRestSensor(ShellyRestAttributeEntity): def state(self): """Return value of sensor.""" return self.attribute_value + + +class ShellySleepingSensor(ShellySleepingBlockAttributeEntity): + """Represent a shelly sleeping sensor.""" + + @property + def state(self): + """Return value of sensor.""" + if self.block is not None: + return self.attribute_value + + return self.last_state diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 341328801cc..85a1fa87d0c 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Before set up, battery-powered devices must be woken up by pressing the button on the device.", + "description": "Before set up, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { "host": "[%key:common::config_flow::data::host%]" } @@ -15,7 +15,7 @@ } }, "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\nBefore set up, battery-powered devices must be woken up by pressing the button on the device." + "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." } }, "error": { diff --git a/homeassistant/components/shelly/translations/ca.json b/homeassistant/components/shelly/translations/ca.json index c2df82c0b16..13cc79ac3d8 100644 --- a/homeassistant/components/shelly/translations/ca.json +++ b/homeassistant/components/shelly/translations/ca.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Vols configurar el {model} a {host}? \n\nAbans de configurar-lo, els dispositius amb bateria s'han de desperar prement el bot\u00f3 del dispositiu." + "description": "Vols configurar el {model} a {host}? \n\nAbans de configurar-lo, els dispositius amb bateria protegits amb contrasenya s'han de desperar prement el bot\u00f3 del dispositiu.\nEls dispositius que no tinguin contrasenya s'afegiran tan bon punt es despertin. Ja pots despertar el dispositiu manualment mitjan\u00e7ant el bot\u00f3 o esperar a la seg\u00fcent transmissi\u00f3 de dades del dispositiu." }, "credentials": { "data": { @@ -24,7 +24,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Abans de configurar-lo, els dispositius amb bateria s'han de desperar prement el bot\u00f3 del dispositiu." + "description": "Abans de configurar-lo, els dispositius amb bateria s'han de desperar, ja pots clicar el bot\u00f3 del dispositiu per a despertar-lo." } } }, diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json index a9ad6092a08..b60d9dfbe3e 100644 --- a/homeassistant/components/shelly/translations/en.json +++ b/homeassistant/components/shelly/translations/en.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\nBefore set up, battery-powered devices must be woken up by pressing the button on the device." + "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." }, "credentials": { "data": { @@ -24,7 +24,7 @@ "data": { "host": "Host" }, - "description": "Before set up, battery-powered devices must be woken up by pressing the button on the device." + "description": "Before set up, battery-powered devices must be woken up, you can now wake the device up using a button on it." } } }, diff --git a/homeassistant/components/shelly/translations/et.json b/homeassistant/components/shelly/translations/et.json index d2514876a81..7059ce6b3d3 100644 --- a/homeassistant/components/shelly/translations/et.json +++ b/homeassistant/components/shelly/translations/et.json @@ -12,7 +12,7 @@ "flow_title": "", "step": { "confirm_discovery": { - "description": "Kas soovid seadistada {model} saidil {host} ?\n\n Enne seadistamist tuleb akutoitega seade \u00e4ratada vajutades seadmel nuppu." + "description": "Kas soovid seadistada seadet {model} saidil {host} ? \n\n Enne seadistamise j\u00e4tkamist tuleb parooliga kaitstud akutoitega seadmed \u00e4ratada.\n Patareitoitega seadmed, mis pole parooliga kaitstud, lisatakse seadme \u00e4rkamisel. N\u00fc\u00fcd saad seadme k\u00e4sitsi \u00fcles \u00e4ratada, kasutades sellel olevat nuppu v\u00f5i oodata seadme j\u00e4rgmist andmete v\u00e4rskendamist." }, "credentials": { "data": { diff --git a/homeassistant/components/shelly/translations/fr.json b/homeassistant/components/shelly/translations/fr.json index e40da9f5e68..e4bdc99db1e 100644 --- a/homeassistant/components/shelly/translations/fr.json +++ b/homeassistant/components/shelly/translations/fr.json @@ -12,7 +12,7 @@ "flow_title": "Shelly: {name}", "step": { "confirm_discovery": { - "description": "Voulez-vous configurer le {model} \u00e0 {host}?" + "description": "Voulez-vous configurer le {model} \u00e0 {host}?\n\nLes appareils aliment\u00e9s par batterie prot\u00e9g\u00e9s par mot de passe doivent \u00eatre r\u00e9veill\u00e9s avant de continuer \u00e0 s\u2019installer.\nLes appareils aliment\u00e9s par batterie qui ne sont pas prot\u00e9g\u00e9s par mot de passe seront ajout\u00e9s lorsque l\u2019appareil se r\u00e9veillera, vous pouvez maintenant r\u00e9veiller manuellement l\u2019appareil \u00e0 l\u2019aide d\u2019un bouton dessus ou attendre la prochaine mise \u00e0 jour des donn\u00e9es de l\u2019appareil." }, "credentials": { "data": { @@ -24,8 +24,24 @@ "data": { "host": "H\u00f4te" }, - "description": "Avant la configuration, l'appareil aliment\u00e9 par batterie doit \u00eatre r\u00e9veill\u00e9 en appuyant sur le bouton de l'appareil." + "description": "Avant la configuration, les appareils aliment\u00e9s par batterie doivent \u00eatre r\u00e9veill\u00e9s, vous pouvez maintenant r\u00e9veiller l'appareil \u00e0 l'aide d'un bouton dessus." } } + }, + "device_automation": { + "trigger_subtype": { + "button": "Bouton", + "button1": "Premier bouton", + "button2": "Deuxi\u00e8me bouton", + "button3": "Troisi\u00e8me bouton" + }, + "trigger_type": { + "double": "{subtype} double-cliqu\u00e9", + "long": " {sous-type} long cliqu\u00e9", + "long_single": "{subtype} clic long et simple clic", + "single": "{subtype} simple clic", + "single_long": "{subtype} simple clic, puis un clic long", + "triple": "{subtype} cliqu\u00e9 trois fois" + } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/it.json b/homeassistant/components/shelly/translations/it.json index 4d486a8f2fa..051cf88dc38 100644 --- a/homeassistant/components/shelly/translations/it.json +++ b/homeassistant/components/shelly/translations/it.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Vuoi impostare {model} su {host} ?\n\n Prima della configurazione, i dispositivi alimentati a batteria devono essere riattivati premendo il pulsante sul dispositivo." + "description": "Vuoi impostare {model} su {host}? \n\nI dispositivi alimentati a batteria protetti da password devono essere riattivati prima di continuare con la configurazione.\nI dispositivi alimentati a batteria che non sono protetti da password verranno aggiunti quando il dispositivo si riattiver\u00e0, ora puoi riattivare manualmente il dispositivo utilizzando un pulsante su di esso o attendere il prossimo aggiornamento dei dati dal dispositivo." }, "credentials": { "data": { @@ -24,7 +24,7 @@ "data": { "host": "Host" }, - "description": "Prima della configurazione, i dispositivi alimentati a batteria devono essere riattivati premendo il pulsante sul dispositivo." + "description": "Prima della configurazione, i dispositivi alimentati a batteria devono essere riattivati, ora puoi riattivare il dispositivo utilizzando un pulsante su di esso." } } }, diff --git a/homeassistant/components/shelly/translations/ko.json b/homeassistant/components/shelly/translations/ko.json index 5fb84e0ac90..914c9a46bd8 100644 --- a/homeassistant/components/shelly/translations/ko.json +++ b/homeassistant/components/shelly/translations/ko.json @@ -1,16 +1,24 @@ { "config": { "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "unsupported_firmware": "\uc774 \uc7a5\uce58\ub294 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ud38c\uc6e8\uc5b4 \ubc84\uc804\uc744 \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4." }, "error": { - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "credentials": { "data": { - "password": "\uc554\ud638", - "username": "\uc0ac\uc6a9\uc790\uba85" + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8" } } } diff --git a/homeassistant/components/shelly/translations/nl.json b/homeassistant/components/shelly/translations/nl.json index 75a2d2771d6..c486b9c6bfe 100644 --- a/homeassistant/components/shelly/translations/nl.json +++ b/homeassistant/components/shelly/translations/nl.json @@ -8,7 +8,7 @@ "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, - "flow_title": "Shelly: {name}", + "flow_title": "{name}", "step": { "confirm_discovery": { "description": "Wilt u het {model} bij {host} instellen? Voordat apparaten op batterijen kunnen worden ingesteld, moet het worden gewekt door op de knop op het apparaat te drukken." @@ -16,7 +16,7 @@ "credentials": { "data": { "password": "Wachtwoord", - "username": "Benutzername" + "username": "Gebruikersnaam" } }, "user": { @@ -25,5 +25,18 @@ } } } + }, + "device_automation": { + "trigger_subtype": { + "button": "Knop", + "button1": "Eerste knop", + "button2": "Tweede knop", + "button3": "Derde knop" + }, + "trigger_type": { + "double": "{subtype} dubbel geklikt", + "single_long": "{subtype} een keer geklikt en daarna lang geklikt", + "triple": "{subtype} driemaal geklikt" + } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/no.json b/homeassistant/components/shelly/translations/no.json index 1606a1acbb1..90cfe3ca906 100644 --- a/homeassistant/components/shelly/translations/no.json +++ b/homeassistant/components/shelly/translations/no.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Vil du konfigurere {model} p\u00e5 {host} ?\n\n F\u00f8r du setter opp, m\u00e5 batteridrevne enheter vekkes ved \u00e5 trykke p\u00e5 knappen p\u00e5 enheten." + "description": "Vil du konfigurere {model} p\u00e5 {host} ? \n\n Batteridrevne enheter som er passordbeskyttet, m\u00e5 vekkes f\u00f8r du fortsetter med konfigurasjonen.\n Batteridrevne enheter som ikke er passordbeskyttet, blir lagt til n\u00e5r enheten v\u00e5kner, du kan n\u00e5 vekke enheten manuelt med en knapp p\u00e5 den eller vente p\u00e5 neste dataoppdatering fra enheten." }, "credentials": { "data": { @@ -24,7 +24,7 @@ "data": { "host": "Vert" }, - "description": "F\u00f8r du setter opp, m\u00e5 batteridrevne enheter vekkes ved \u00e5 trykke p\u00e5 knappen p\u00e5 enheten." + "description": "F\u00f8r du setter opp, m\u00e5 batteridrevne enheter vekkes, du kan n\u00e5 vekke enheten med en knapp p\u00e5 den." } } }, diff --git a/homeassistant/components/shelly/translations/pl.json b/homeassistant/components/shelly/translations/pl.json index cd8ffac7138..b0c4dd11b1b 100644 --- a/homeassistant/components/shelly/translations/pl.json +++ b/homeassistant/components/shelly/translations/pl.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Czy chcesz skonfigurowa\u0107 {model} ({host})?\n\nPrzed skonfigurowaniem urz\u0105dzenia zasilane bateryjnie nale\u017cy, wybudzi\u0107 naciskaj\u0105c przycisk na urz\u0105dzeniu." + "description": "Czy chcesz skonfigurowa\u0107 {model} ({host})?\n\nUrz\u0105dzenia zasilane bateryjnie, z ustawionym has\u0142em, nale\u017cy wybudzi\u0107 przed konfiguracj\u0105.\nUrz\u0105dzenia zasilane bateryjnie, bez ustawionego has\u0142a, zostan\u0105 dodane, gdy si\u0119 wybudz\u0105. Mo\u017cesz r\u0119cznie wybudzi\u0107 urz\u0105dzenie przyciskiem na obudowie lub poczeka\u0107 na aktualizacj\u0119 danych z urz\u0105dzenia." }, "credentials": { "data": { @@ -24,13 +24,13 @@ "data": { "host": "Nazwa hosta lub adres IP" }, - "description": "Przed skonfigurowaniem urz\u0105dzenia zasilane bateryjnie nale\u017cy, wybudzi\u0107 naciskaj\u0105c przycisk na urz\u0105dzeniu." + "description": "Przed skonfigurowaniem urz\u0105dzenia zasilane bateryjnie nale\u017cy, wybudzi\u0107 naciskaj\u0105c przycisk na obudowie." } } }, "device_automation": { "trigger_subtype": { - "button": "Przycisk", + "button": "przycisk", "button1": "pierwszy", "button2": "drugi", "button3": "trzeci" diff --git a/homeassistant/components/shelly/translations/ru.json b/homeassistant/components/shelly/translations/ru.json index 5a3a40ac9f8..a570cb7f9fb 100644 --- a/homeassistant/components/shelly/translations/ru.json +++ b/homeassistant/components/shelly/translations/ru.json @@ -6,13 +6,13 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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}", "step": { "confirm_discovery": { - "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 {model} ({host}) ?\n\n\u041f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0440\u0430\u0431\u043e\u0442\u0430\u044e\u0449\u0438\u0435 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u0432\u0435\u0441\u0442\u0438 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430, \u043d\u0430\u0436\u0430\u0432 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435." + "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 {model} ({host})?\n\n\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u044e\u0442 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438 \u0438 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u044b \u043f\u0430\u0440\u043e\u043b\u0435\u043c, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u0442\u044c, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443.\n\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u044e\u0442 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438 \u0438 \u043d\u0435 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u044b \u043f\u0430\u0440\u043e\u043b\u0435\u043c, \u0431\u0443\u0434\u0443\u0442 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u044b, \u043a\u043e\u0433\u0434\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u044b\u0439\u0434\u0435\u0442 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0436\u0430\u0442\u044c \u043d\u0430 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435, \u0442\u0435\u043c \u0441\u0430\u043c\u044b\u043c \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u0432 \u0435\u0433\u043e, \u043b\u0438\u0431\u043e \u0434\u043e\u0436\u0434\u0430\u0442\u044c\u0441\u044f \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u0433\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0434\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430." }, "credentials": { "data": { diff --git a/homeassistant/components/shelly/translations/zh-Hant.json b/homeassistant/components/shelly/translations/zh-Hant.json index 8f315208135..abc0b627423 100644 --- a/homeassistant/components/shelly/translations/zh-Hant.json +++ b/homeassistant/components/shelly/translations/zh-Hant.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u4f4d\u65bc {host} \u7684 {model}\uff1f\n\n\u958b\u59cb\u8a2d\u5b9a\u524d\uff0c\u5fc5\u9808\u6309\u4e0b\u88dd\u7f6e\u4e0a\u7684\u6309\u9215\u4ee5\u559a\u9192\u96fb\u6c60\u4f9b\u96fb\u88dd\u7f6e\u3002" + "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u4f4d\u65bc {host} \u7684 {model}\uff1f\n\n\u958b\u59cb\u8a2d\u5b9a\u524d\uff0c\u5fc5\u9808\u6309\u4e0b\u88dd\u7f6e\u4e0a\u7684\u6309\u9215\u4ee5\u559a\u9192\u96fb\u6c60\u4f9b\u96fb\u88dd\u7f6e\u3002\n\u4e0d\u5177\u5bc6\u78bc\u4fdd\u8b77\u7684\u96fb\u6c60\u4f9b\u96fb\u88dd\u7f6e\uff0c\u53ef\u4ee5\u65bc\u559a\u9192\u5f8c\u65b0\u589e\u3002\u53ef\u4ee5\u4f7f\u7528\u88dd\u7f6e\u4e0a\u7684\u6309\u9215\u6216\u7b49\u5f85\u88dd\u7f6e\u4e0b\u4e00\u6b21\u8cc7\u6599\u66f4\u65b0\u6642\u9032\u884c\u624b\u52d5\u559a\u9192\u3002" }, "credentials": { "data": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 97d8bda609b..0058374cfe7 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,8 +6,9 @@ from typing import List, Optional, Tuple import aioshelly -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import singleton from homeassistant.util.dt import parse_datetime, utcnow from .const import ( @@ -110,7 +111,7 @@ def get_device_channel_name( def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool: """Return true if input button settings is set to a momentary type.""" # Shelly Button type is fixed to momentary and no btn_type - if settings["device"]["type"] == "SHBTN-1": + if settings["device"]["type"] in ("SHBTN-1", "SHBTN-2"): return True button = settings.get("relays") or settings.get("lights") or settings.get("inputs") @@ -157,7 +158,7 @@ def get_input_triggers( else: subtype = f"button{int(block.channel)+1}" - if device.settings["device"]["type"] == "SHBTN-1": + if device.settings["device"]["type"] in ("SHBTN-1", "SHBTN-2"): trigger_types = SHBTN_1_INPUTS_EVENTS_TYPES elif device.settings["device"]["type"] == "SHIX3-1": trigger_types = SHIX3_1_INPUTS_EVENTS_TYPES @@ -182,3 +183,30 @@ def get_device_wrapper(hass: HomeAssistant, device_id: str): return wrapper return None + + +@singleton.singleton("shelly_coap") +async def get_coap_context(hass): + """Get CoAP context to be used in all Shelly devices.""" + context = aioshelly.COAP() + await context.initialize() + + @callback + def shutdown_listener(ev): + context.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) + + return context + + +def get_device_sleep_period(settings: dict) -> int: + """Return the device sleep period in seconds or 0 for non sleeping devices.""" + sleep_period = 0 + + if settings.get("sleep_mode", False): + sleep_period = settings["sleep_mode"]["period"] + if settings["sleep_mode"]["unit"] == "h": + sleep_period *= 60 # hours to minutes + + return sleep_period * 60 # minutes to seconds diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 1831f894cec..e438bf3b8f4 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -15,17 +15,21 @@ from homeassistant.util.json import load_json, save_json from .const import DOMAIN ATTR_NAME = "name" +ATTR_COMPLETE = "complete" _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) EVENT = "shopping_list_updated" -ITEM_UPDATE_SCHEMA = vol.Schema({"complete": bool, ATTR_NAME: str}) +ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) PERSISTENCE = ".shopping_list.json" SERVICE_ADD_ITEM = "add_item" SERVICE_COMPLETE_ITEM = "complete_item" - +SERVICE_INCOMPLETE_ITEM = "incomplete_item" +SERVICE_COMPLETE_ALL = "complete_all" +SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): vol.Any(None, cv.string)}) +SERVICE_LIST_SCHEMA = vol.Schema({}) WS_TYPE_SHOPPING_LIST_ITEMS = "shopping_list/items" WS_TYPE_SHOPPING_LIST_ADD_ITEM = "shopping_list/items/add" @@ -92,6 +96,27 @@ async def async_setup_entry(hass, config_entry): else: await data.async_update(item["id"], {"name": name, "complete": True}) + async def incomplete_item_service(call): + """Mark the item provided via `name` as incomplete.""" + data = hass.data[DOMAIN] + name = call.data.get(ATTR_NAME) + if name is None: + return + try: + item = [item for item in data.items if item["name"] == name][0] + except IndexError: + _LOGGER.error("Restoring of item failed: %s cannot be found", name) + else: + await data.async_update(item["id"], {"name": name, "complete": False}) + + async def complete_all_service(call): + """Mark all items in the list as complete.""" + await data.async_update_list({"complete": True}) + + async def incomplete_all_service(call): + """Mark all items in the list as incomplete.""" + await data.async_update_list({"complete": False}) + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -101,6 +126,24 @@ async def async_setup_entry(hass, config_entry): hass.services.async_register( DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ITEM, + incomplete_item_service, + schema=SERVICE_ITEM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_ALL, + complete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ALL, + incomplete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) @@ -165,6 +208,13 @@ class ShoppingData: self.items = [itm for itm in self.items if not itm["complete"]] await self.hass.async_add_executor_job(self.save) + async def async_update_list(self, info): + """Update all items in the list.""" + for item in self.items: + item.update(info) + await self.hass.async_add_executor_job(self.save) + return self.items + @callback def async_reorder(self, item_ids): """Reorder items.""" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index 04457e2abec..73540210232 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -1,12 +1,36 @@ add_item: - description: Adds an item to the shopping list. + name: Add item + description: Add an item to the shopping list. fields: name: + name: Name description: The name of the item to add. + required: true example: Beer + selector: + text: + complete_item: - description: Marks an item as completed in the shopping list. It does not remove the item. + name: Complete item + description: Mark an item as completed in the shopping list. fields: name: - description: The name of the item to mark as completed. + name: Name + description: The name of the item to mark as completed (without removing). + required: true example: Beer + selector: + text: + +incomplete_item: + description: Marks an item as incomplete in the shopping list. + fields: + name: + description: The name of the item to mark as incomplete. + example: Beer + +complete_all: + description: Marks all items as completed in the shopping list. It does not remove the items. + +incomplete_all: + description: Marks all items as incomplete in the shopping list. diff --git a/homeassistant/components/shopping_list/translations/ko.json b/homeassistant/components/shopping_list/translations/ko.json index 247fa8d9f4d..a576567a87f 100644 --- a/homeassistant/components/shopping_list/translations/ko.json +++ b/homeassistant/components/shopping_list/translations/ko.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "\uc7a5\ubcf4\uae30\ubaa9\ub85d\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { - "description": "\uc7a5\ubcf4\uae30\ubaa9\ub85d\uc744 \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", - "title": "\uc7a5\ubcf4\uae30\ubaa9\ub85d" + "description": "\uc7a5\ubcf4\uae30 \ubaa9\ub85d\uc744 \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\uc7a5\ubcf4\uae30 \ubaa9\ub85d" } } }, - "title": "\uc7a5\ubcf4\uae30\ubaa9\ub85d" + "title": "\uc7a5\ubcf4\uae30 \ubaa9\ub85d" } \ No newline at end of file diff --git a/homeassistant/components/shopping_list/translations/sv.json b/homeassistant/components/shopping_list/translations/sv.json new file mode 100644 index 00000000000..0202ae3a53f --- /dev/null +++ b/homeassistant/components/shopping_list/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten har redan konfigurerats" + }, + "step": { + "user": { + "description": "Vill du konfigurera ink\u00f6pslistan?", + "title": "Ink\u00f6pslista" + } + } + }, + "title": "Ink\u00f6pslista" +} \ No newline at end of file diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 99902b8dd36..aa9519fd68b 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -2,6 +2,6 @@ "domain": "sighthound", "name": "Sighthound", "documentation": "https://www.home-assistant.io/integrations/sighthound", - "requirements": ["pillow==8.1.0", "simplehound==0.3"], + "requirements": ["pillow==8.1.1", "simplehound==0.3"], "codeowners": ["@robmarkcole"] } diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 1d101534157..5a83dec69f0 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -8,13 +8,12 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_EVENT, CONF_PASSWORD import homeassistant.helpers.config_validation as cv ATTR_ENCRYPTED = "encrypted" CONF_DEVICE_KEY = "device_key" -CONF_EVENT = "event" CONF_SALT = "salt" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -44,7 +43,6 @@ class SimplePushNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a Simplepush user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) if self._password: diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index b18bafb0bbf..45deb938b59 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,6 +3,6 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.6.4"], + "requirements": ["simplisafe-python==9.6.9"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/simplisafe/translations/et.json b/homeassistant/components/simplisafe/translations/et.json index b98a121046a..7b6e317b922 100644 --- a/homeassistant/components/simplisafe/translations/et.json +++ b/homeassistant/components/simplisafe/translations/et.json @@ -12,7 +12,7 @@ }, "step": { "mfa": { - "description": "Kontrollige oma e-posti: link SimpliSafe-lt. P\u00e4rast lingi kontrollimist naase siia, et viia l\u00f5pule sidumise installimine.", + "description": "Kontrolli oma e-posti: link SimpliSafe-lt. P\u00e4rast lingi kontrollimist naase siia, et viia l\u00f5pule sidumise installimine.", "title": "SimpliSafe mitmeastmeline autentimine" }, "reauth_confirm": { diff --git a/homeassistant/components/simplisafe/translations/it.json b/homeassistant/components/simplisafe/translations/it.json index fdd69b39efc..b5ce2a26702 100644 --- a/homeassistant/components/simplisafe/translations/it.json +++ b/homeassistant/components/simplisafe/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso.", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "identifier_exists": "Account gi\u00e0 registrato", @@ -20,7 +20,7 @@ "password": "Password" }, "description": "Il token di accesso \u00e8 scaduto o \u00e8 stato revocato. Inserisci la tua password per ricollegare il tuo account.", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" }, "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/ko.json b/homeassistant/components/simplisafe/translations/ko.json index 57ba4a88fc1..c5c1b057ea8 100644 --- a/homeassistant/components/simplisafe/translations/ko.json +++ b/homeassistant/components/simplisafe/translations/ko.json @@ -1,12 +1,21 @@ { "config": { "abort": { - "already_configured": "\uc774 SimpliSafe \uacc4\uc815\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uc774 SimpliSafe \uacc4\uc815\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4.", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + }, + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" + }, "user": { "data": { "code": "\ucf54\ub4dc (Home Assistant UI \uc5d0\uc11c \uc0ac\uc6a9\ub428)", diff --git a/homeassistant/components/simplisafe/translations/nl.json b/homeassistant/components/simplisafe/translations/nl.json index b285b288525..d3196c591cb 100644 --- a/homeassistant/components/simplisafe/translations/nl.json +++ b/homeassistant/components/simplisafe/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Dit SimpliSafe-account is al in gebruik." + "already_configured": "Dit SimpliSafe-account is al in gebruik.", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "identifier_exists": "Account bestaat al", @@ -13,7 +14,8 @@ "data": { "password": "Wachtwoord" }, - "description": "Uw toegangstoken is verlopen of ingetrokken. Voer uw wachtwoord in om uw account opnieuw te koppelen." + "description": "Uw toegangstoken is verlopen of ingetrokken. Voer uw wachtwoord in om uw account opnieuw te koppelen.", + "title": "Verifieer de integratie opnieuw" }, "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/ru.json b/homeassistant/components/simplisafe/translations/ru.json index 94b0e6a0975..abe0542c926 100644 --- a/homeassistant/components/simplisafe/translations/ru.json +++ b/homeassistant/components/simplisafe/translations/ru.json @@ -6,7 +6,7 @@ }, "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "still_awaiting_mfa": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", "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/simplisafe/translations/sv.json b/homeassistant/components/simplisafe/translations/sv.json index a1bfb4400be..a4e8e052073 100644 --- a/homeassistant/components/simplisafe/translations/sv.json +++ b/homeassistant/components/simplisafe/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Det h\u00e4r SimpliSafe-kontot har redan konfigurerats." + }, "error": { "identifier_exists": "Kontot \u00e4r redan registrerat" }, diff --git a/homeassistant/components/simplisafe/translations/zh-Hant.json b/homeassistant/components/simplisafe/translations/zh-Hant.json index ad5323d3957..27064ed1055 100644 --- a/homeassistant/components/simplisafe/translations/zh-Hant.json +++ b/homeassistant/components/simplisafe/translations/zh-Hant.json @@ -19,7 +19,7 @@ "data": { "password": "\u5bc6\u78bc" }, - "description": "\u5b58\u53d6\u5bc6\u9470\u5df2\u7d93\u904e\u671f\u6216\u53d6\u6d88\uff0c\u8acb\u8f38\u5165\u5bc6\u78bc\u4ee5\u91cd\u65b0\u9023\u7d50\u5e33\u865f\u3002", + "description": "\u5b58\u53d6\u6b0a\u6756\u5df2\u7d93\u904e\u671f\u6216\u53d6\u6d88\uff0c\u8acb\u8f38\u5165\u5bc6\u78bc\u4ee5\u91cd\u65b0\u9023\u7d50\u5e33\u865f\u3002", "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" }, "user": { diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 512731ab355..8949a58fa01 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=10) # Sensor types: Name, device_class, event SENSOR_TYPES = { diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 119d9a366d6..94bab40a3b7 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PATH, CONF_SCAN_INTERVAL, + CONF_SENSORS, CONF_SSL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, @@ -27,7 +28,6 @@ CONF_CUSTOM = "custom" CONF_FACTOR = "factor" CONF_GROUP = "group" CONF_KEY = "key" -CONF_SENSORS = "sensors" CONF_UNIT = "unit" GROUPS = ["user", "installer"] @@ -86,7 +86,6 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up SMA WebConnect sensor.""" - # Check config again during load - dependency available config = _check_sensor_schema(config) diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index c6d208626e4..450874b3f35 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -56,7 +56,6 @@ class SmappeeFlowHandler( if self.is_cloud_device_already_added(): return self.async_abort(reason="already_configured_device") - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { CONF_IP_ADDRESS: discovery_info["host"], @@ -76,7 +75,6 @@ class SmappeeFlowHandler( return self.async_abort(reason="already_configured_device") if user_input is None: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 serialnumber = self.context.get(CONF_SERIALNUMBER) return self.async_show_form( step_id="zeroconf_confirm", @@ -84,7 +82,6 @@ class SmappeeFlowHandler( errors=errors, ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 ip_address = self.context.get(CONF_IP_ADDRESS) serial_number = self.context.get(CONF_SERIALNUMBER) diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index ddbff4e7738..a6dda75ac72 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smappee", "dependencies": ["http"], "requirements": [ - "pysmappee==0.2.16" + "pysmappee==0.2.17" ], "codeowners": [ "@bsmappee" diff --git a/homeassistant/components/smappee/translations/ko.json b/homeassistant/components/smappee/translations/ko.json index b3e37ee6d01..8509b65ca09 100644 --- a/homeassistant/components/smappee/translations/ko.json +++ b/homeassistant/components/smappee/translations/ko.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured_device": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_configured_device": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "step": { "local": { diff --git a/homeassistant/components/smappee/translations/nl.json b/homeassistant/components/smappee/translations/nl.json index 86f4a40c6f9..10a4fe2efab 100644 --- a/homeassistant/components/smappee/translations/nl.json +++ b/homeassistant/components/smappee/translations/nl.json @@ -3,7 +3,11 @@ "abort": { "already_configured_device": "Apparaat is al geconfigureerd", "already_configured_local_device": "Lokale apparaten zijn al geconfigureerd. Verwijder deze eerst voordat u een cloudapparaat configureert.", - "cannot_connect": "Kan geen verbinding maken" + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "cannot_connect": "Kan geen verbinding maken", + "invalid_mdns": "Niet-ondersteund apparaat voor de Smappee-integratie.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen.", + "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [check de helpsectie]({docs_url})" }, "step": { "local": { @@ -11,6 +15,9 @@ "host": "Host" }, "description": "Voer de host in om de lokale Smappee-integratie te starten" + }, + "pick_implementation": { + "title": "Kies een authenticatie methode" } } } diff --git a/homeassistant/components/smart_meter_texas/translations/ru.json b/homeassistant/components/smart_meter_texas/translations/ru.json index 9fe75df9c3f..3f4677a050e 100644 --- a/homeassistant/components/smart_meter_texas/translations/ru.json +++ b/homeassistant/components/smart_meter_texas/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/smarthab/translations/ko.json b/homeassistant/components/smarthab/translations/ko.json index 4b15acd2f38..d39931cbc03 100644 --- a/homeassistant/components/smarthab/translations/ko.json +++ b/homeassistant/components/smarthab/translations/ko.json @@ -1,7 +1,9 @@ { "config": { "error": { - "service": "SmartHab \uc5d0 \uc811\uc18d\ud558\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc11c\ube44\uc2a4\uac00 \ub2e4\uc6b4\ub418\uc5c8\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc5f0\uacb0\uc744 \ud655\uc778\ud574\uc8fc\uc138\uc694." + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "service": "SmartHab \uc5d0 \uc811\uc18d\ud558\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc11c\ube44\uc2a4\uac00 \ub2e4\uc6b4\ub418\uc5c8\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc5f0\uacb0\uc744 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/smarthab/translations/nl.json b/homeassistant/components/smarthab/translations/nl.json index 9dabac8aa55..7f5fc7fe27c 100644 --- a/homeassistant/components/smarthab/translations/nl.json +++ b/homeassistant/components/smarthab/translations/nl.json @@ -1,12 +1,14 @@ { "config": { "error": { + "invalid_auth": "Ongeldige authenticatie", "service": "Fout bij het bereiken van SmartHab. De service is mogelijk uitgevallen. Controleer uw verbinding.", "unknown": "Onverwachte fout" }, "step": { "user": { "data": { + "email": "E-mail", "password": "Wachtwoord" } } diff --git a/homeassistant/components/smarthab/translations/ru.json b/homeassistant/components/smarthab/translations/ru.json index cea090f51d2..45e3698034f 100644 --- a/homeassistant/components/smarthab/translations/ru.json +++ b/homeassistant/components/smarthab/translations/ru.json @@ -1,7 +1,7 @@ { "config": { "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "service": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u043f\u043e\u043f\u044b\u0442\u043a\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a SmartHab. \u0421\u0435\u0440\u0432\u0438\u0441 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435.", "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/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index e366f6bb3e3..4cd451e2416 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -1,22 +1,20 @@ """Support for fans through the SmartThings cloud API.""" +import math from typing import Optional, Sequence from pysmartthings import Capability -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, ) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN -VALUE_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} -SPEED_TO_VALUE = {v: k for k, v in VALUE_TO_SPEED.items()} +SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -42,24 +40,28 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" - async def async_set_speed(self, speed: str): - """Set the speed of the fan.""" - value = SPEED_TO_VALUE[speed] - await self._device.set_fan_speed(value, set_status=True) + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage is None: + await self._device.switch_on(set_status=True) + elif percentage == 0: + await self._device.switch_off(set_status=True) + else: + value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + await self._device.set_fan_speed(value, set_status=True) # State is set optimistically in the command above, therefore update # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the fan on.""" - if speed is not None: - value = SPEED_TO_VALUE[speed] - await self._device.set_fan_speed(value, set_status=True) - else: - await self._device.switch_on(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: """Turn the fan off.""" @@ -74,14 +76,14 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): return self._device.status.switch @property - def speed(self) -> str: - """Return the current speed.""" - return VALUE_TO_SPEED[self._device.status.fan_speed] + def percentage(self) -> str: + """Return the current speed percentage.""" + return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) @property def supported_features(self) -> int: diff --git a/homeassistant/components/smartthings/translations/et.json b/homeassistant/components/smartthings/translations/et.json index 04cd0d70218..18d6076898d 100644 --- a/homeassistant/components/smartthings/translations/et.json +++ b/homeassistant/components/smartthings/translations/et.json @@ -19,7 +19,7 @@ "data": { "access_token": "Juurdep\u00e4\u00e4sut\u00f5end" }, - "description": "Sisesta SmartThingsi [isiklik juurdep\u00e4\u00e4suluba] ( {token_url} ), mis on loodud vastavalt [juhistele] ( {component_url} ). Seda kasutatakse Home Assistanti sidumise loomiseks teie SmartThingsi kontol.", + "description": "Sisesta SmartThingsi [isiklik juurdep\u00e4\u00e4suluba] ( {token_url} ), mis on loodud vastavalt [juhistele] ( {component_url} ). Seda kasutatakse Home Assistanti sidumise loomiseks SmartThingsi kontol.", "title": "Sisesta isiklik juurdep\u00e4\u00e4suluba (PAT)" }, "select_location": { diff --git a/homeassistant/components/smartthings/translations/zh-Hant.json b/homeassistant/components/smartthings/translations/zh-Hant.json index d9a17e46058..88360c75678 100644 --- a/homeassistant/components/smartthings/translations/zh-Hant.json +++ b/homeassistant/components/smartthings/translations/zh-Hant.json @@ -6,9 +6,9 @@ }, "error": { "app_setup_error": "\u7121\u6cd5\u8a2d\u5b9a SmartApp\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", - "token_forbidden": "\u5bc6\u9470\u4e0d\u5177\u6240\u9700\u7684 OAuth \u7bc4\u570d\u3002", - "token_invalid_format": "\u5bc6\u9470\u5fc5\u9808\u70ba UID/GUID \u683c\u5f0f", - "token_unauthorized": "\u5bc6\u9470\u7121\u6548\u6216\u4e0d\u518d\u5177\u6709\u6388\u6b0a\u3002", + "token_forbidden": "\u6b0a\u6756\u4e0d\u5177\u6240\u9700\u7684 OAuth \u7bc4\u570d\u3002", + "token_invalid_format": "\u6b0a\u6756\u5fc5\u9808\u70ba UID/GUID \u683c\u5f0f", + "token_unauthorized": "\u6b0a\u6756\u7121\u6548\u6216\u4e0d\u518d\u5177\u6709\u6388\u6b0a\u3002", "webhook_error": "SmartThings \u7121\u6cd5\u8a8d\u8b49 Webhook URL\u3002\u8acb\u78ba\u8a8d Webhook URL \u53ef\u7531\u7db2\u8def\u5b58\u53d6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002" }, "step": { @@ -17,10 +17,10 @@ }, "pat": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470" + "access_token": "\u5b58\u53d6\u6b0a\u6756" }, - "description": "\u8acb\u8f38\u5165\u8ddf\u96a8\u6b64[\u6559\u5b78]({component_url}) \u6240\u5efa\u7acb\u7684 SmartThings [\u500b\u4eba\u5b58\u53d6\u5bc6\u9470]({token_url})\u3002\u5c07\u4f7f\u7528 SmartThings \u5e33\u865f\u65b0\u589e Home Assistant \u6574\u5408\u3002", - "title": "\u8f38\u5165\u500b\u4eba\u5b58\u53d6\u5bc6\u9470" + "description": "\u8acb\u8f38\u5165\u8ddf\u96a8\u6b64[\u6559\u5b78]({component_url}) \u6240\u5efa\u7acb\u7684 SmartThings [\u500b\u4eba\u5b58\u53d6\u6b0a\u6756]({token_url})\u3002\u5c07\u4f7f\u7528 SmartThings \u5e33\u865f\u65b0\u589e Home Assistant \u6574\u5408\u3002", + "title": "\u8f38\u5165\u500b\u4eba\u5b58\u53d6\u6b0a\u6756" }, "select_location": { "data": { diff --git a/homeassistant/components/smarttub/__init__.py b/homeassistant/components/smarttub/__init__.py new file mode 100644 index 00000000000..457af4b7bc0 --- /dev/null +++ b/homeassistant/components/smarttub/__init__.py @@ -0,0 +1,54 @@ +"""SmartTub integration.""" +import asyncio +import logging + +from .const import DOMAIN, SMARTTUB_CONTROLLER +from .controller import SmartTubController + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor", "climate", "light", "sensor", "switch"] + + +async def async_setup(hass, config): + """Set up smarttub component.""" + + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a smarttub config entry.""" + + controller = SmartTubController(hass) + hass.data[DOMAIN][entry.entry_id] = { + SMARTTUB_CONTROLLER: controller, + } + + if not await controller.async_setup_entry(entry): + return False + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Remove a smarttub config entry.""" + if not all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ): + return False + + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py new file mode 100644 index 00000000000..52dbfd71a37 --- /dev/null +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -0,0 +1,40 @@ +"""Platform for binary sensor integration.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) + +from .const import DOMAIN, SMARTTUB_CONTROLLER +from .entity import SmartTubSensorBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up binary sensor entities for the binary sensors in the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [SmartTubOnline(controller.coordinator, spa) for spa in controller.spas] + + async_add_entities(entities) + + +class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): + """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" + + def __init__(self, coordinator, spa): + """Initialize the entity.""" + super().__init__(coordinator, spa, "Online", "online") + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._state is True + + @property + def device_class(self) -> str: + """Return the device class for this entity.""" + return DEVICE_CLASS_CONNECTIVITY diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py new file mode 100644 index 00000000000..66c03a22e1f --- /dev/null +++ b/homeassistant/components/smarttub/climate.py @@ -0,0 +1,142 @@ +"""Platform for climate integration.""" +import logging + +from smarttub import Spa + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + PRESET_ECO, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.util.temperature import convert as convert_temperature + +from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER +from .entity import SmartTubEntity + +_LOGGER = logging.getLogger(__name__) + +PRESET_DAY = "day" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up climate entity for the thermostat in the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [ + SmartTubThermostat(controller.coordinator, spa) for spa in controller.spas + ] + + async_add_entities(entities) + + +class SmartTubThermostat(SmartTubEntity, ClimateEntity): + """The target water temperature for the spa.""" + + PRESET_MODES = { + Spa.HeatMode.AUTO: PRESET_NONE, + Spa.HeatMode.ECONOMY: PRESET_ECO, + Spa.HeatMode.DAY: PRESET_DAY, + } + + HEAT_MODES = {v: k for k, v in PRESET_MODES.items()} + + HVAC_ACTIONS = { + "OFF": CURRENT_HVAC_IDLE, + "ON": CURRENT_HVAC_HEAT, + } + + def __init__(self, coordinator, spa): + """Initialize the entity.""" + super().__init__(coordinator, spa, "thermostat") + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + return self.HVAC_ACTIONS.get(self.spa_status.heater) + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT] + + @property + def hvac_mode(self): + """Return the current hvac mode. + + SmartTub devices don't seem to have the option of disabling the heater, + so this is always HVAC_MODE_HEAT. + """ + return HVAC_MODE_HEAT + + async def async_set_hvac_mode(self, hvac_mode: str): + """Set new target hvac mode. + + As with hvac_mode, we don't really have an option here. + """ + if hvac_mode == HVAC_MODE_HEAT: + return + raise NotImplementedError(hvac_mode) + + @property + def min_temp(self): + """Return the minimum temperature.""" + min_temp = DEFAULT_MIN_TEMP + return convert_temperature(min_temp, TEMP_CELSIUS, self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + max_temp = DEFAULT_MAX_TEMP + return convert_temperature(max_temp, TEMP_CELSIUS, self.temperature_unit) + + @property + def supported_features(self): + """Return the set of supported features. + + Only target temperature is supported. + """ + return SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + + @property + def preset_mode(self): + """Return the current preset mode.""" + return self.PRESET_MODES[self.spa_status.heat_mode] + + @property + def preset_modes(self): + """Return the available preset modes.""" + return list(self.PRESET_MODES.values()) + + @property + def current_temperature(self): + """Return the current water temperature.""" + return self.spa_status.water.temperature + + @property + def target_temperature(self): + """Return the target water temperature.""" + return self.spa_status.set_temperature + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + await self.spa.set_temperature(temperature) + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str): + """Activate the specified preset mode.""" + heat_mode = self.HEAT_MODES[preset_mode] + await self.spa.set_heat_mode(heat_mode) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py new file mode 100644 index 00000000000..8f3ed17f93a --- /dev/null +++ b/homeassistant/components/smarttub/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow to configure the SmartTub integration.""" +import logging + +from smarttub import LoginFailed +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN # pylint: disable=unused-import +from .controller import SmartTubController + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} +) + + +_LOGGER = logging.getLogger(__name__) + + +class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """SmartTub configuration flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + controller = SmartTubController(self.hass) + try: + account = await controller.login( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except LoginFailed: + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + existing_entry = await self.async_set_unique_id(account.id) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=user_input[CONF_EMAIL], data=user_input) diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py new file mode 100644 index 00000000000..0b97926cc43 --- /dev/null +++ b/homeassistant/components/smarttub/const.py @@ -0,0 +1,22 @@ +"""smarttub constants.""" + +DOMAIN = "smarttub" + +EVENT_SMARTTUB = "smarttub" + +SMARTTUB_CONTROLLER = "smarttub_controller" + +SCAN_INTERVAL = 60 + +POLLING_TIMEOUT = 10 +API_TIMEOUT = 5 + +DEFAULT_MIN_TEMP = 18.5 +DEFAULT_MAX_TEMP = 40 + +# the device doesn't remember any state for the light, so we have to choose a +# mode (smarttub.SpaLight.LightMode) when turning it on. There is no white +# mode. +DEFAULT_LIGHT_EFFECT = "purple" +# default to 50% brightness +DEFAULT_LIGHT_BRIGHTNESS = 128 diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py new file mode 100644 index 00000000000..bf8de2f4e2e --- /dev/null +++ b/homeassistant/components/smarttub/controller.py @@ -0,0 +1,122 @@ +"""Interface to the SmartTub API.""" + +import asyncio +from datetime import timedelta +import logging + +from aiohttp import client_exceptions +import async_timeout +from smarttub import APIError, LoginFailed, SmartTub +from smarttub.api import Account + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +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.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, POLLING_TIMEOUT, SCAN_INTERVAL +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + + +class SmartTubController: + """Interface between Home Assistant and the SmartTub API.""" + + def __init__(self, hass): + """Initialize an interface to SmartTub.""" + self._hass = hass + self._account = None + self.spas = set() + self._spa_devices = {} + + self.coordinator = None + + async def async_setup_entry(self, entry): + """Perform initial setup. + + Authenticate, query static state, set up polling, and otherwise make + ready for normal operations . + """ + + try: + self._account = await self.login( + entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD] + ) + except LoginFailed: + # credentials were changed or invalidated, we need new ones + + return False + except ( + asyncio.TimeoutError, + client_exceptions.ClientOSError, + client_exceptions.ServerDisconnectedError, + client_exceptions.ContentTypeError, + ) as err: + raise ConfigEntryNotReady from err + + self.spas = await self._account.get_spas() + + self.coordinator = DataUpdateCoordinator( + self._hass, + _LOGGER, + name=DOMAIN, + update_method=self.async_update_data, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + + await self.coordinator.async_refresh() + + await self.async_register_devices(entry) + + return True + + async def async_update_data(self): + """Query the API and return the new state.""" + + data = {} + try: + async with async_timeout.timeout(POLLING_TIMEOUT): + for spa in self.spas: + data[spa.id] = await self._get_spa_data(spa) + except APIError as err: + raise UpdateFailed(err) from err + + return data + + async def _get_spa_data(self, spa): + status, pumps, lights = await asyncio.gather( + spa.get_status(), + spa.get_pumps(), + spa.get_lights(), + ) + return { + "status": status, + "pumps": {pump.id: pump for pump in pumps}, + "lights": {light.zone: light for light in lights}, + } + + async def async_register_devices(self, entry): + """Register devices with the device registry for all spas.""" + device_registry = await dr.async_get_registry(self._hass) + for spa in self.spas: + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, spa.id)}, + manufacturer=spa.brand, + name=get_spa_name(spa), + model=spa.model, + ) + self._spa_devices[spa.id] = device + + async def login(self, email, password) -> Account: + """Retrieve the account corresponding to the specified email and password. + + Returns None if the credentials are invalid. + """ + + api = SmartTub(async_get_clientsession(self._hass)) + + await api.login(email, password) + return await api.get_account() diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py new file mode 100644 index 00000000000..8be956a2b70 --- /dev/null +++ b/homeassistant/components/smarttub/entity.py @@ -0,0 +1,71 @@ +"""SmartTub integration.""" +import logging + +import smarttub + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + + +class SmartTubEntity(CoordinatorEntity): + """Base class for SmartTub entities.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, spa: smarttub.Spa, entity_type + ): + """Initialize the entity. + + Given a spa id and a short name for the entity, we provide basic device + info, name, unique id, etc. for all derived entities. + """ + + super().__init__(coordinator) + self.spa = spa + self._entity_type = entity_type + + @property + def unique_id(self) -> str: + """Return a unique id for the entity.""" + return f"{self.spa.id}-{self._entity_type}" + + @property + def device_info(self) -> str: + """Return device info.""" + return { + "identifiers": {(DOMAIN, self.spa.id)}, + "manufacturer": self.spa.brand, + "model": self.spa.model, + } + + @property + def name(self) -> str: + """Return the name of the entity.""" + spa_name = get_spa_name(self.spa) + return f"{spa_name} {self._entity_type}" + + @property + def spa_status(self) -> smarttub.SpaState: + """Retrieve the result of Spa.get_status().""" + + return self.coordinator.data[self.spa.id].get("status") + + +class SmartTubSensorBase(SmartTubEntity): + """Base class for SmartTub sensors.""" + + def __init__(self, coordinator, spa, sensor_name, attr_name): + """Initialize the entity.""" + super().__init__(coordinator, spa, sensor_name) + self._attr_name = attr_name + + @property + def _state(self): + """Retrieve the underlying state from the spa.""" + return getattr(self.spa_status, self._attr_name) diff --git a/homeassistant/components/smarttub/helpers.py b/homeassistant/components/smarttub/helpers.py new file mode 100644 index 00000000000..a6f2d09c38f --- /dev/null +++ b/homeassistant/components/smarttub/helpers.py @@ -0,0 +1,8 @@ +"""Helper functions for SmartTub integration.""" + +import smarttub + + +def get_spa_name(spa: smarttub.Spa) -> str: + """Return the name of the specified spa.""" + return f"{spa.brand} {spa.model}" diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py new file mode 100644 index 00000000000..a4ada7c3024 --- /dev/null +++ b/homeassistant/components/smarttub/light.py @@ -0,0 +1,141 @@ +"""Platform for light integration.""" +import logging + +from smarttub import SpaLight + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + EFFECT_COLORLOOP, + SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, + LightEntity, +) + +from .const import ( + DEFAULT_LIGHT_BRIGHTNESS, + DEFAULT_LIGHT_EFFECT, + DOMAIN, + SMARTTUB_CONTROLLER, +) +from .entity import SmartTubEntity +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up entities for any lights in the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [ + SmartTubLight(controller.coordinator, light) + for spa in controller.spas + for light in await spa.get_lights() + ] + + async_add_entities(entities) + + +class SmartTubLight(SmartTubEntity, LightEntity): + """A light on a spa.""" + + def __init__(self, coordinator, light): + """Initialize the entity.""" + super().__init__(coordinator, light.spa, "light") + self.light_zone = light.zone + + @property + def light(self) -> SpaLight: + """Return the underlying SpaLight object for this entity.""" + return self.coordinator.data[self.spa.id]["lights"][self.light_zone] + + @property + def unique_id(self) -> str: + """Return a unique ID for this light entity.""" + return f"{super().unique_id}-{self.light_zone}" + + @property + def name(self) -> str: + """Return a name for this light entity.""" + spa_name = get_spa_name(self.spa) + return f"{spa_name} Light {self.light_zone}" + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + + # SmartTub intensity is 0..100 + return self._smarttub_to_hass_brightness(self.light.intensity) + + @staticmethod + def _smarttub_to_hass_brightness(intensity): + if intensity in (0, 1): + return 0 + return round(intensity * 255 / 100) + + @staticmethod + def _hass_to_smarttub_brightness(brightness): + return round(brightness * 100 / 255) + + @property + def is_on(self): + """Return true if the light is on.""" + return self.light.mode != SpaLight.LightMode.OFF + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_EFFECT + + @property + def effect(self): + """Return the current effect.""" + mode = self.light.mode.name.lower() + if mode in self.effect_list: + return mode + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + effects = [ + effect + for effect in map(self._light_mode_to_effect, SpaLight.LightMode) + if effect is not None + ] + + return effects + + @staticmethod + def _light_mode_to_effect(light_mode: SpaLight.LightMode): + if light_mode == SpaLight.LightMode.OFF: + return None + if light_mode == SpaLight.LightMode.HIGH_SPEED_COLOR_WHEEL: + return EFFECT_COLORLOOP + + return light_mode.name.lower() + + @staticmethod + def _effect_to_light_mode(effect): + if effect == EFFECT_COLORLOOP: + return SpaLight.LightMode.HIGH_SPEED_COLOR_WHEEL + + return SpaLight.LightMode[effect.upper()] + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + + mode = self._effect_to_light_mode(kwargs.get(ATTR_EFFECT, DEFAULT_LIGHT_EFFECT)) + intensity = self._hass_to_smarttub_brightness( + kwargs.get(ATTR_BRIGHTNESS, DEFAULT_LIGHT_BRIGHTNESS) + ) + + await self.light.set_mode(mode, intensity) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self.light.set_mode(self.light.LightMode.OFF, 0) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json new file mode 100644 index 00000000000..292ce81b4fb --- /dev/null +++ b/homeassistant/components/smarttub/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "smarttub", + "name": "SmartTub", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/smarttub", + "dependencies": [], + "codeowners": ["@mdz"], + "requirements": [ + "python-smarttub==0.0.17" + ], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py new file mode 100644 index 00000000000..be3d60c0241 --- /dev/null +++ b/homeassistant/components/smarttub/sensor.py @@ -0,0 +1,103 @@ +"""Platform for sensor integration.""" +from enum import Enum +import logging + +from .const import DOMAIN, SMARTTUB_CONTROLLER +from .entity import SmartTubSensorBase + +_LOGGER = logging.getLogger(__name__) + +ATTR_DURATION = "duration" +ATTR_LAST_UPDATED = "last_updated" +ATTR_MODE = "mode" +ATTR_START_HOUR = "start_hour" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up sensor entities for the sensors in the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [] + for spa in controller.spas: + entities.extend( + [ + SmartTubSensor(controller.coordinator, spa, "State", "state"), + SmartTubSensor( + controller.coordinator, spa, "Flow Switch", "flow_switch" + ), + SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), + SmartTubSensor(controller.coordinator, spa, "UV", "uv"), + SmartTubSensor( + controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" + ), + SmartTubSensor( + controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" + ), + SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), + SmartTubSecondaryFiltrationCycle(controller.coordinator, spa), + ] + ) + + async_add_entities(entities) + + +class SmartTubSensor(SmartTubSensorBase): + """Generic class for SmartTub status sensors.""" + + @property + def state(self) -> str: + """Return the current state of the sensor.""" + if isinstance(self._state, Enum): + return self._state.name.lower() + return self._state.lower() + + +class SmartTubPrimaryFiltrationCycle(SmartTubSensor): + """The primary filtration cycle.""" + + def __init__(self, coordinator, spa): + """Initialize the entity.""" + super().__init__( + coordinator, spa, "primary filtration cycle", "primary_filtration" + ) + + @property + def state(self) -> str: + """Return the current state of the sensor.""" + return self._state.status.name.lower() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = self._state + return { + ATTR_DURATION: state.duration, + ATTR_LAST_UPDATED: state.last_updated.isoformat(), + ATTR_MODE: state.mode.name.lower(), + ATTR_START_HOUR: state.start_hour, + } + + +class SmartTubSecondaryFiltrationCycle(SmartTubSensor): + """The secondary filtration cycle.""" + + def __init__(self, coordinator, spa): + """Initialize the entity.""" + super().__init__( + coordinator, spa, "Secondary Filtration Cycle", "secondary_filtration" + ) + + @property + def state(self) -> str: + """Return the current state of the sensor.""" + return self._state.status.name.lower() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = self._state + return { + ATTR_LAST_UPDATED: state.last_updated.isoformat(), + ATTR_MODE: state.mode.name.lower(), + } diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json new file mode 100644 index 00000000000..8ba888a9ffb --- /dev/null +++ b/homeassistant/components/smarttub/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Login", + "description": "Enter your SmartTub email address and password to login", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py new file mode 100644 index 00000000000..7e4c83f6feb --- /dev/null +++ b/homeassistant/components/smarttub/switch.py @@ -0,0 +1,82 @@ +"""Platform for switch integration.""" +import logging + +import async_timeout +from smarttub import SpaPump + +from homeassistant.components.switch import SwitchEntity + +from .const import API_TIMEOUT, DOMAIN, SMARTTUB_CONTROLLER +from .entity import SmartTubEntity +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up switch entities for the pumps on the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [ + SmartTubPump(controller.coordinator, pump) + for spa in controller.spas + for pump in await spa.get_pumps() + ] + + async_add_entities(entities) + + +class SmartTubPump(SmartTubEntity, SwitchEntity): + """A pump on a spa.""" + + def __init__(self, coordinator, pump: SpaPump): + """Initialize the entity.""" + super().__init__(coordinator, pump.spa, "pump") + self.pump_id = pump.id + self.pump_type = pump.type + + @property + def pump(self) -> SpaPump: + """Return the underlying SpaPump object for this entity.""" + return self.coordinator.data[self.spa.id]["pumps"][self.pump_id] + + @property + def unique_id(self) -> str: + """Return a unique ID for this pump entity.""" + return f"{super().unique_id}-{self.pump_id}" + + @property + def name(self) -> str: + """Return a name for this pump entity.""" + spa_name = get_spa_name(self.spa) + if self.pump_type == SpaPump.PumpType.CIRCULATION: + return f"{spa_name} Circulation Pump" + if self.pump_type == SpaPump.PumpType.JET: + return f"{spa_name} Jet {self.pump_id}" + return f"{spa_name} pump {self.pump_id}" + + @property + def is_on(self) -> bool: + """Return True if the pump is on.""" + return self.pump.state != SpaPump.PumpState.OFF + + async def async_turn_on(self, **kwargs) -> None: + """Turn the pump on.""" + + # the API only supports toggling + if not self.is_on: + await self.async_toggle() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the pump off.""" + + # the API only supports toggling + if self.is_on: + await self.async_toggle() + + async def async_toggle(self, **kwargs) -> None: + """Toggle the pump on or off.""" + async with async_timeout.timeout(API_TIMEOUT): + await self.pump.toggle() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/smarttub/translations/ca.json b/homeassistant/components/smarttub/translations/ca.json new file mode 100644 index 00000000000..6d882abeee6 --- /dev/null +++ b/homeassistant/components/smarttub/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "description": "Introdueix el correu electr\u00f2nic i la contrasenya de SmartTub per iniciar sessi\u00f3", + "title": "Inici de sessi\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/cs.json b/homeassistant/components/smarttub/translations/cs.json new file mode 100644 index 00000000000..6be2df92286 --- /dev/null +++ b/homeassistant/components/smarttub/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + }, + "title": "P\u0159ihl\u00e1\u0161en\u00ed" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/de.json b/homeassistant/components/smarttub/translations/de.json new file mode 100644 index 00000000000..fbb3411a6c5 --- /dev/null +++ b/homeassistant/components/smarttub/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/en.json b/homeassistant/components/smarttub/translations/en.json new file mode 100644 index 00000000000..4cf93091887 --- /dev/null +++ b/homeassistant/components/smarttub/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Enter your SmartTub email address and password to login", + "title": "Login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/es.json b/homeassistant/components/smarttub/translations/es.json new file mode 100644 index 00000000000..df5b4122bc4 --- /dev/null +++ b/homeassistant/components/smarttub/translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Introduzca su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a de SmartTub para iniciar sesi\u00f3n", + "title": "Inicio de sesi\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/et.json b/homeassistant/components/smarttub/translations/et.json new file mode 100644 index 00000000000..676edee1584 --- /dev/null +++ b/homeassistant/components/smarttub/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + }, + "description": "Sisselogimiseks sisesta oma SmartTubi e-posti aadress ja salas\u00f5na", + "title": "Sisselogimine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/fr.json b/homeassistant/components/smarttub/translations/fr.json new file mode 100644 index 00000000000..15dfa04fc78 --- /dev/null +++ b/homeassistant/components/smarttub/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a \u00e9t\u00e9 un succ\u00e8s" + }, + "error": { + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + }, + "description": "Entrez votre adresse e-mail et votre mot de passe SmartTub pour vous connecter", + "title": "Connexion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/it.json b/homeassistant/components/smarttub/translations/it.json new file mode 100644 index 00000000000..64aed0996f3 --- /dev/null +++ b/homeassistant/components/smarttub/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "description": "Inserisci il tuo indirizzo e-mail e la password SmartTub per accedere", + "title": "Accesso" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/ko.json b/homeassistant/components/smarttub/translations/ko.json new file mode 100644 index 00000000000..fab7e511034 --- /dev/null +++ b/homeassistant/components/smarttub/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "email": "\uc774\uba54\uc77c", + "password": "\ube44\ubc00\ubc88\ud638" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/nl.json b/homeassistant/components/smarttub/translations/nl.json new file mode 100644 index 00000000000..a5f20db8a32 --- /dev/null +++ b/homeassistant/components/smarttub/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Inloggen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/no.json b/homeassistant/components/smarttub/translations/no.json new file mode 100644 index 00000000000..7f1c5982d28 --- /dev/null +++ b/homeassistant/components/smarttub/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "Skriv inn din SmartTub e-postadresse og passord for \u00e5 logge p\u00e5", + "title": "P\u00e5logging" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/pl.json b/homeassistant/components/smarttub/translations/pl.json new file mode 100644 index 00000000000..2c3f097d6d0 --- /dev/null +++ b/homeassistant/components/smarttub/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a sw\u00f3j adres e-mail SmartTub oraz has\u0142o, aby si\u0119 zalogowa\u0107", + "title": "Logowanie" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/ru.json b/homeassistant/components/smarttub/translations/ru.json new file mode 100644 index 00000000000..44f27877d93 --- /dev/null +++ b/homeassistant/components/smarttub/translations/ru.json @@ -0,0 +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.", + "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": { + "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" + }, + "description": "\u0414\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c SmartTub.", + "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/zh-Hant.json b/homeassistant/components/smarttub/translations/zh-Hant.json new file mode 100644 index 00000000000..9491e7d2f25 --- /dev/null +++ b/homeassistant/components/smarttub/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\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": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u8f38\u5165\u767b\u5165 SmartTub \u4e4b Email \u5730\u5740\u8207\u5bc6\u78bc\u3002", + "title": "\u767b\u5165" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index e46198b051b..481d2e56d3d 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -1,26 +1,24 @@ """Platform to control a Salda Smarty XP/XV ventilation unit.""" import logging +import math -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import DOMAIN, SIGNAL_UPDATE_SMARTY _LOGGER = logging.getLogger(__name__) -SPEED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - -SPEED_MAPPING = {1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} -SPEED_TO_MODE = {v: k for k, v in SPEED_MAPPING.items()} +DEFAULT_ON_PERCENTAGE = 66 +SPEED_RANGE = (1, 3) # off is not included async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -37,8 +35,7 @@ class SmartyFan(FanEntity): def __init__(self, name, smarty): """Initialize the entity.""" self._name = name - self._speed = SPEED_OFF - self._state = None + self._smarty_fan_speed = 0 self._smarty = smarty @property @@ -61,69 +58,64 @@ class SmartyFan(FanEntity): """Return the list of supported features.""" return SUPPORT_SET_SPEED - @property - def speed_list(self): - """List of available fan modes.""" - return SPEED_LIST - @property def is_on(self): """Return state of the fan.""" - return self._state + return bool(self._smarty_fan_speed) @property - def speed(self) -> str: - """Return speed of the fan.""" - return self._speed + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) - def set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - _LOGGER.debug("Set the fan speed to %s", speed) - if speed == SPEED_OFF: + @property + def percentage(self) -> int: + """Return speed percentage of the fan.""" + if self._smarty_fan_speed == 0: + return 0 + return ranged_value_to_percentage(SPEED_RANGE, self._smarty_fan_speed) + + def set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + _LOGGER.debug("Set the fan percentage to %s", percentage) + if percentage == 0: self.turn_off() - else: - self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed)) - self._speed = speed - self._state = True + return - def turn_on(self, speed=None, **kwargs): + fan_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + if not self._smarty.set_fan_speed(fan_speed): + raise HomeAssistantError( + f"Failed to set the fan speed percentage to {percentage}" + ) + + self._smarty_fan_speed = fan_speed + self.schedule_update_ha_state() + + def turn_on(self, speed=None, percentage=None, preset_mode=None, **kwargs): """Turn on the fan.""" _LOGGER.debug("Turning on fan. Speed is %s", speed) - if speed is None: - if self._smarty.turn_on(SPEED_TO_MODE.get(self._speed)): - self._state = True - self._speed = SPEED_MEDIUM - else: - if self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed)): - self._speed = speed - self._state = True - - self.schedule_update_ha_state() + self.set_percentage(percentage or DEFAULT_ON_PERCENTAGE) def turn_off(self, **kwargs): """Turn off the fan.""" _LOGGER.debug("Turning off fan") - if self._smarty.turn_off(): - self._state = False + if not self._smarty.turn_off(): + raise HomeAssistantError("Failed to turn off the fan") + self._smarty_fan_speed = 0 self.schedule_update_ha_state() async def async_added_to_hass(self): """Call to update fan.""" - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback + ) + ) @callback def _update_callback(self): """Call update method.""" - self.async_schedule_update_ha_state(True) - - def update(self): - """Update state.""" _LOGGER.debug("Updating state") - result = self._smarty.fan_speed - if result: - self._speed = SPEED_MAPPING[result] - _LOGGER.debug("Speed is %s, Mode is %s", self._speed, result) - self._state = True - else: - self._state = False + self._smarty_fan_speed = self._smarty.fan_speed + self.async_write_ha_state() diff --git a/homeassistant/components/sms/config_flow.py b/homeassistant/components/sms/config_flow.py index 52f3a403ed1..01c1d182c93 100644 --- a/homeassistant/components/sms/config_flow.py +++ b/homeassistant/components/sms/config_flow.py @@ -27,7 +27,7 @@ async def get_imei_from_config(hass: core.HomeAssistant, data): raise CannotConnect try: imei = await gateway.get_imei_async() - except gammu.GSMError as err: # pylint: disable=no-member + except gammu.GSMError as err: raise CannotConnect from err finally: await gateway.terminate_async() diff --git a/homeassistant/components/sms/translations/nl.json b/homeassistant/components/sms/translations/nl.json index 75dd593982a..ddcc54d239f 100644 --- a/homeassistant/components/sms/translations/nl.json +++ b/homeassistant/components/sms/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { "cannot_connect": "Kon niet verbinden", diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index e3e59676bf5..8609e578e5e 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,4 +1,5 @@ """Support for SolarEdge Monitoring API.""" +from abc import abstractmethod from datetime import date, datetime import logging @@ -7,8 +8,14 @@ import solaredge from stringcase import snakecase from homeassistant.const import CONF_API_KEY, DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( CONF_SITE_ID, @@ -37,14 +44,20 @@ async def async_setup_entry(hass, entry, async_add_entities): _LOGGER.error("SolarEdge site is not active") return _LOGGER.debug("Credentials correct and site is active") - except KeyError: + except KeyError as ex: _LOGGER.error("Missing details data in SolarEdge response") - return - except (ConnectTimeout, HTTPError): + raise ConfigEntryNotReady from ex + except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Could not retrieve details from SolarEdge API") - return + raise ConfigEntryNotReady from ex + + sensor_factory = SolarEdgeSensorFactory( + hass, entry.title, entry.data[CONF_SITE_ID], api + ) + for service in sensor_factory.all_services: + service.async_setup() + await service.coordinator.async_refresh() - sensor_factory = SolarEdgeSensorFactory(entry.title, entry.data[CONF_SITE_ID], api) entities = [] for sensor_key in SENSOR_TYPES: sensor = sensor_factory.create_sensor(sensor_key) @@ -56,15 +69,17 @@ async def async_setup_entry(hass, entry, async_add_entities): class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__(self, platform_name, site_id, api): + def __init__(self, hass, platform_name, site_id, api): """Initialize the factory.""" self.platform_name = platform_name - details = SolarEdgeDetailsDataService(api, site_id) - overview = SolarEdgeOverviewDataService(api, site_id) - inventory = SolarEdgeInventoryDataService(api, site_id) - flow = SolarEdgePowerFlowDataService(api, site_id) - energy = SolarEdgeEnergyDetailsService(api, site_id) + details = SolarEdgeDetailsDataService(hass, api, site_id) + overview = SolarEdgeOverviewDataService(hass, api, site_id) + inventory = SolarEdgeInventoryDataService(hass, api, site_id) + flow = SolarEdgePowerFlowDataService(hass, api, site_id) + energy = SolarEdgeEnergyDetailsService(hass, api, site_id) + + self.all_services = (details, overview, inventory, flow, energy) self.services = {"site_details": (SolarEdgeDetailsSensor, details)} @@ -102,39 +117,30 @@ class SolarEdgeSensorFactory: return sensor_class(self.platform_name, sensor_key, service) -class SolarEdgeSensor(Entity): +class SolarEdgeSensor(CoordinatorEntity, Entity): """Abstract class for a solaredge sensor.""" def __init__(self, platform_name, sensor_key, data_service): """Initialize the sensor.""" + super().__init__(data_service.coordinator) self.platform_name = platform_name self.sensor_key = sensor_key self.data_service = data_service - self._state = None - - self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] - self._icon = SENSOR_TYPES[self.sensor_key][3] + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return SENSOR_TYPES[self.sensor_key][2] @property def name(self): """Return the name.""" return "{} ({})".format(self.platform_name, SENSOR_TYPES[self.sensor_key][1]) - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - @property def icon(self): """Return the sensor icon.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + return SENSOR_TYPES[self.sensor_key][3] class SolarEdgeOverviewSensor(SolarEdgeSensor): @@ -146,31 +152,24 @@ class SolarEdgeOverviewSensor(SolarEdgeSensor): self._json_key = SENSOR_TYPES[self.sensor_key][0] - def update(self): - """Get the latest data from the sensor and update the state.""" - self.data_service.update() - self._state = self.data_service.data.get(self._json_key) + @property + def state(self): + """Return the state of the sensor.""" + return self.data_service.data.get(self._json_key) class SolarEdgeDetailsSensor(SolarEdgeSensor): """Representation of an SolarEdge Monitoring API details sensor.""" - def __init__(self, platform_name, sensor_key, data_service): - """Initialize the details sensor.""" - super().__init__(platform_name, sensor_key, data_service) - - self._attributes = {} - @property def device_state_attributes(self): """Return the state attributes.""" - return self._attributes + return self.data_service.attributes - def update(self): - """Get the latest details and update state and attributes.""" - self.data_service.update() - self._state = self.data_service.data - self._attributes = self.data_service.attributes + @property + def state(self): + """Return the state of the sensor.""" + return self.data_service.data class SolarEdgeInventorySensor(SolarEdgeSensor): @@ -182,18 +181,15 @@ class SolarEdgeInventorySensor(SolarEdgeSensor): self._json_key = SENSOR_TYPES[self.sensor_key][0] - self._attributes = {} - @property def device_state_attributes(self): """Return the state attributes.""" - return self._attributes + return self.data_service.attributes.get(self._json_key) - def update(self): - """Get the latest inventory data and update state and attributes.""" - self.data_service.update() - self._state = self.data_service.data.get(self._json_key) - self._attributes = self.data_service.attributes.get(self._json_key) + @property + def state(self): + """Return the state of the sensor.""" + return self.data_service.data.get(self._json_key) class SolarEdgeEnergyDetailsSensor(SolarEdgeSensor): @@ -205,19 +201,20 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensor): self._json_key = SENSOR_TYPES[self.sensor_key][0] - self._attributes = {} - @property def device_state_attributes(self): """Return the state attributes.""" - return self._attributes + return self.data_service.attributes.get(self._json_key) - def update(self): - """Get the latest inventory data and update state and attributes.""" - self.data_service.update() - self._state = self.data_service.data.get(self._json_key) - self._attributes = self.data_service.attributes.get(self._json_key) - self._unit_of_measurement = self.data_service.unit + @property + def state(self): + """Return the state of the sensor.""" + return self.data_service.data.get(self._json_key) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.data_service.unit class SolarEdgePowerFlowSensor(SolarEdgeSensor): @@ -229,24 +226,25 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensor): self._json_key = SENSOR_TYPES[self.sensor_key][0] - self._attributes = {} - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - @property def device_class(self): """Device Class.""" return DEVICE_CLASS_POWER - def update(self): - """Get the latest inventory data and update state and attributes.""" - self.data_service.update() - self._state = self.data_service.data.get(self._json_key) - self._attributes = self.data_service.attributes.get(self._json_key) - self._unit_of_measurement = self.data_service.unit + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self.data_service.attributes.get(self._json_key) + + @property + def state(self): + """Return the state of the sensor.""" + return self.data_service.data.get(self._json_key) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.data_service.unit class SolarEdgeStorageLevelSensor(SolarEdgeSensor): @@ -263,18 +261,19 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensor): """Return the device_class of the device.""" return DEVICE_CLASS_BATTERY - def update(self): - """Get the latest inventory data and update state and attributes.""" - self.data_service.update() + @property + def state(self): + """Return the state of the sensor.""" attr = self.data_service.attributes.get(self._json_key) if attr and "soc" in attr: - self._state = attr["soc"] + return attr["soc"] + return None class SolarEdgeDataService: """Get and update the latest data.""" - def __init__(self, api, site_id): + def __init__(self, hass, api, site_id): """Initialize the data object.""" self.api = api self.site_id = site_id @@ -282,22 +281,49 @@ class SolarEdgeDataService: self.data = {} self.attributes = {} + self.hass = hass + self.coordinator = None + + @callback + def async_setup(self): + """Coordinator creation.""" + self.coordinator = DataUpdateCoordinator( + self.hass, + _LOGGER, + name=str(self), + update_method=self.async_update_data, + update_interval=self.update_interval, + ) + + @property + @abstractmethod + def update_interval(self): + """Update interval.""" + + @abstractmethod + def update(self): + """Update data in executor.""" + + async def async_update_data(self): + """Update data.""" + await self.hass.async_add_executor_job(self.update) + class SolarEdgeOverviewDataService(SolarEdgeDataService): """Get and update the latest overview data.""" - @Throttle(OVERVIEW_UPDATE_DELAY) + @property + def update_interval(self): + """Update interval.""" + return OVERVIEW_UPDATE_DELAY + def update(self): """Update the data from the SolarEdge Monitoring API.""" try: data = self.api.get_overview(self.site_id) overview = data["overview"] - except KeyError: - _LOGGER.error("Missing overview data, skipping update") - return - except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") - return + except KeyError as ex: + raise UpdateFailed("Missing overview data, skipping update") from ex self.data = {} @@ -316,25 +342,25 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService): class SolarEdgeDetailsDataService(SolarEdgeDataService): """Get and update the latest details data.""" - def __init__(self, api, site_id): + def __init__(self, hass, api, site_id): """Initialize the details data service.""" - super().__init__(api, site_id) + super().__init__(hass, api, site_id) self.data = None - @Throttle(DETAILS_UPDATE_DELAY) + @property + def update_interval(self): + """Update interval.""" + return DETAILS_UPDATE_DELAY + def update(self): """Update the data from the SolarEdge Monitoring API.""" try: data = self.api.get_details(self.site_id) details = data["details"] - except KeyError: - _LOGGER.error("Missing details data, skipping update") - return - except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") - return + except KeyError as ex: + raise UpdateFailed("Missing details data, skipping update") from ex self.data = None self.attributes = {} @@ -362,18 +388,18 @@ class SolarEdgeDetailsDataService(SolarEdgeDataService): class SolarEdgeInventoryDataService(SolarEdgeDataService): """Get and update the latest inventory data.""" - @Throttle(INVENTORY_UPDATE_DELAY) + @property + def update_interval(self): + """Update interval.""" + return INVENTORY_UPDATE_DELAY + def update(self): """Update the data from the SolarEdge Monitoring API.""" try: data = self.api.get_inventory(self.site_id) inventory = data["Inventory"] - except KeyError: - _LOGGER.error("Missing inventory data, skipping update") - return - except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") - return + except KeyError as ex: + raise UpdateFailed("Missing inventory data, skipping update") from ex self.data = {} self.attributes = {} @@ -388,13 +414,17 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService): class SolarEdgeEnergyDetailsService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, api, site_id): + def __init__(self, hass, api, site_id): """Initialize the power flow data service.""" - super().__init__(api, site_id) + super().__init__(hass, api, site_id) self.unit = None - @Throttle(ENERGY_DETAILS_DELAY) + @property + def update_interval(self): + """Update interval.""" + return ENERGY_DETAILS_DELAY + def update(self): """Update the data from the SolarEdge Monitoring API.""" try: @@ -409,12 +439,8 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): time_unit="DAY", ) energy_details = data["energyDetails"] - except KeyError: - _LOGGER.error("Missing power flow data, skipping update") - return - except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") - return + except KeyError as ex: + raise UpdateFailed("Missing power flow data, skipping update") from ex if "meters" not in energy_details: _LOGGER.debug( @@ -449,24 +475,24 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): class SolarEdgePowerFlowDataService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, api, site_id): + def __init__(self, hass, api, site_id): """Initialize the power flow data service.""" - super().__init__(api, site_id) + super().__init__(hass, api, site_id) self.unit = None - @Throttle(POWER_FLOW_UPDATE_DELAY) + @property + def update_interval(self): + """Update interval.""" + return POWER_FLOW_UPDATE_DELAY + def update(self): """Update the data from the SolarEdge Monitoring API.""" try: data = self.api.get_current_power_flow(self.site_id) power_flow = data["siteCurrentPowerFlow"] - except KeyError: - _LOGGER.error("Missing power flow data, skipping update") - return - except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") - return + except KeyError as ex: + raise UpdateFailed("Missing power flow data, skipping update") from ex power_from = [] power_to = [] diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json index 6fa6fdf264f..3eea6678d03 100644 --- a/homeassistant/components/solaredge/translations/fr.json +++ b/homeassistant/components/solaredge/translations/fr.json @@ -1,9 +1,12 @@ { "config": { "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", "site_exists": "Ce site est d\u00e9j\u00e0 configur\u00e9" }, "error": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "could_not_connect": "Impossible de se connecter \u00e0 l'API solaredge", "invalid_api_key": "Cl\u00e9 API invalide", "site_exists": "Ce site est d\u00e9j\u00e0 configur\u00e9", "site_not_active": "The site n'est pas actif" diff --git a/homeassistant/components/solaredge/translations/ko.json b/homeassistant/components/solaredge/translations/ko.json index eb2d8c42a14..8544cdd143d 100644 --- a/homeassistant/components/solaredge/translations/ko.json +++ b/homeassistant/components/solaredge/translations/ko.json @@ -1,9 +1,12 @@ { "config": { "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "site_exists": "\uc774 site_id \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_api_key": "API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "site_exists": "\uc774 site_id \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/solaredge/translations/nl.json b/homeassistant/components/solaredge/translations/nl.json index 4b468218410..3fe28971f29 100644 --- a/homeassistant/components/solaredge/translations/nl.json +++ b/homeassistant/components/solaredge/translations/nl.json @@ -1,10 +1,15 @@ { "config": { "abort": { + "already_configured": "Apparaat is al geconfigureerd", "site_exists": "Deze site_id is al geconfigureerd" }, "error": { - "site_exists": "Deze site_id is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "could_not_connect": "Kon geen verbinding maken met de solaredge API", + "invalid_api_key": "Ongeldige API-sleutel", + "site_exists": "Deze site_id is al geconfigureerd", + "site_not_active": "De site is niet actief" }, "step": { "user": { diff --git a/homeassistant/components/solarlog/translations/ko.json b/homeassistant/components/solarlog/translations/ko.json index 66c6a2177d4..22002c52cef 100644 --- a/homeassistant/components/solarlog/translations/ko.json +++ b/homeassistant/components/solarlog/translations/ko.json @@ -5,7 +5,7 @@ }, "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 8ab7335dd5c..a31b404dad7 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "You can only configure one Soma account.", - "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_timeout": "Timeout generating authorize URL.", "missing_configuration": "The Soma component is not configured. Please follow the documentation.", "result_error": "SOMA Connect responded with error status.", "connection_error": "Failed to connect to SOMA Connect." diff --git a/homeassistant/components/soma/translations/cs.json b/homeassistant/components/soma/translations/cs.json index 5a27562df71..ba1261c1100 100644 --- a/homeassistant/components/soma/translations/cs.json +++ b/homeassistant/components/soma/translations/cs.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "M\u016f\u017eete nastavit pouze jeden \u00fa\u010det Soma.", - "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el.", "connection_error": "P\u0159ipojen\u00ed k za\u0159\u00edzen\u00ed SOMA Connect se nezda\u0159ilo.", "missing_configuration": "Integrace Soma nen\u00ed nastavena. Postupujte podle dokumentace.", "result_error": "SOMA Connect odpov\u011bd\u011blo chybov\u00fdm stavem." diff --git a/homeassistant/components/soma/translations/en.json b/homeassistant/components/soma/translations/en.json index 6f28ee53ae2..fb5d17ac59d 100644 --- a/homeassistant/components/soma/translations/en.json +++ b/homeassistant/components/soma/translations/en.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "You can only configure one Soma account.", - "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_timeout": "Timeout generating authorize URL.", "connection_error": "Failed to connect to SOMA Connect.", "missing_configuration": "The Soma component is not configured. Please follow the documentation.", "result_error": "SOMA Connect responded with error status." diff --git a/homeassistant/components/soma/translations/it.json b/homeassistant/components/soma/translations/it.json index 0119fca7388..237ce347cb0 100644 --- a/homeassistant/components/soma/translations/it.json +++ b/homeassistant/components/soma/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "\u00c8 possibile configurare un solo account Soma.", - "authorize_url_timeout": "Timeout durante la generazione dell'URL di autorizzazione.", + "authorize_url_timeout": "Tempo scaduto nella generazione dell'URL di autorizzazione.", "connection_error": "Impossibile connettersi a SOMA Connect.", "missing_configuration": "Il componente Soma non \u00e8 configurato. Si prega di seguire la documentazione.", "result_error": "SOMA Connect ha risposto con stato di errore." diff --git a/homeassistant/components/soma/translations/ko.json b/homeassistant/components/soma/translations/ko.json index b987c7b2b73..83c2f01ff8b 100644 --- a/homeassistant/components/soma/translations/ko.json +++ b/homeassistant/components/soma/translations/ko.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "\ud558\ub098\uc758 Soma \uacc4\uc815\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "connection_error": "SOMA Connect \uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", "missing_configuration": "Soma \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", "result_error": "SOMA Connect \uac00 \uc624\ub958 \uc0c1\ud0dc\ub85c \uc751\ub2f5\ud588\uc2b5\ub2c8\ub2e4." diff --git a/homeassistant/components/soma/translations/no.json b/homeassistant/components/soma/translations/no.json index f9b64dc8483..a399f430329 100644 --- a/homeassistant/components/soma/translations/no.json +++ b/homeassistant/components/soma/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_setup": "Du kan bare konfigurere \u00e9n Soma-konto.", - "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", + "authorize_url_timeout": "Tidsavbrudd genererer godkjennelses-URL.", "connection_error": "Kunne ikke koble til SOMA Connect.", "missing_configuration": "Soma-komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", "result_error": "SOMA Connect svarte med feilstatus." diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index 2fc83ea71de..ac32b9d5379 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.somfy import config_flow from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_OPTIMISTIC from homeassistant.core import callback from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -25,7 +25,7 @@ from homeassistant.helpers.update_coordinator import ( ) from . import api -from .const import API, CONF_OPTIMISTIC, COORDINATOR, DOMAIN +from .const import API, COORDINATOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -188,7 +188,7 @@ class SomfyEntity(CoordinatorEntity, Entity): "identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "model": self.device.type, - "via_hub": (DOMAIN, self.device.parent_id), + "via_device": (DOMAIN, self.device.parent_id), # For the moment, Somfy only returns their own device. "manufacturer": "Somfy", } diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py index 00a2738f4fe..99d6dca06ee 100644 --- a/homeassistant/components/somfy/climate.py +++ b/homeassistant/components/somfy/climate.py @@ -48,7 +48,6 @@ HVAC_MODES_MAPPING = {HvacState.COOL: HVAC_MODE_COOL, HvacState.HEAT: HVAC_MODE_ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy climate platform.""" - domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] api = domain_data[API] diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py index aca93be66cb..128d6eb76bb 100644 --- a/homeassistant/components/somfy/const.py +++ b/homeassistant/components/somfy/const.py @@ -3,4 +3,3 @@ DOMAIN = "somfy" COORDINATOR = "coordinator" API = "api" -CONF_OPTIMISTIC = "optimistic" diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py index e7308558127..d227bc31227 100644 --- a/homeassistant/components/somfy/cover.py +++ b/homeassistant/components/somfy/cover.py @@ -18,11 +18,11 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) -from homeassistant.const import STATE_CLOSED, STATE_OPEN +from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN from homeassistant.helpers.restore_state import RestoreEntity from . import SomfyEntity -from .const import API, CONF_OPTIMISTIC, COORDINATOR, DOMAIN +from .const import API, COORDINATOR, DOMAIN BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value} SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value} @@ -35,7 +35,6 @@ SUPPORTED_CATEGORIES = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy cover platform.""" - domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] api = domain_data[API] diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py index 1becc929adc..996a95348a4 100644 --- a/homeassistant/components/somfy/sensor.py +++ b/homeassistant/components/somfy/sensor.py @@ -13,7 +13,6 @@ SUPPORTED_CATEGORIES = {Category.HVAC.value} async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy sensor platform.""" - domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] api = domain_data[API] diff --git a/homeassistant/components/somfy/switch.py b/homeassistant/components/somfy/switch.py index 14328953367..66eef99d6b5 100644 --- a/homeassistant/components/somfy/switch.py +++ b/homeassistant/components/somfy/switch.py @@ -10,7 +10,6 @@ from .const import API, COORDINATOR, DOMAIN async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy switch platform.""" - domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] api = domain_data[API] diff --git a/homeassistant/components/somfy/translations/ko.json b/homeassistant/components/somfy/translations/ko.json index 5119670f766..8b4f4ff752f 100644 --- a/homeassistant/components/somfy/translations/ko.json +++ b/homeassistant/components/somfy/translations/ko.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "missing_configuration": "Somfy \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})", - "single_instance_allowed": "\uc774\ubbf8 \uc124\uc815\ub418\uc5b4 \uc788\uc74c. \ud558\ub098\uc758 \uc124\uc815\ub9cc \uac00\ub2a5\ud568." + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Somfy \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "pick_implementation": { - "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd" + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" } } } diff --git a/homeassistant/components/somfy/translations/nl.json b/homeassistant/components/somfy/translations/nl.json index 423dbb6a2bb..b7f077f2c73 100644 --- a/homeassistant/components/somfy/translations/nl.json +++ b/homeassistant/components/somfy/translations/nl.json @@ -3,6 +3,7 @@ "abort": { "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "missing_configuration": "Het Somfy-component is niet geconfigureerd. Gelieve de documentatie te volgen.", + "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [check de helpsectie]({docs_url})", "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." }, "create_entry": { diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index ce69d265b55..b6d647b9a3b 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -72,7 +72,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = dhcp_discovery[HOSTNAME] self.mac = formatted_mac self.ip_address = dhcp_discovery[IP_ADDRESS] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"ip": self.ip_address, "mac": self.mac} return await self.async_step_user() diff --git a/homeassistant/components/somfy_mylink/translations/fr.json b/homeassistant/components/somfy_mylink/translations/fr.json index 96904b6038d..bee2ea3ba13 100644 --- a/homeassistant/components/somfy_mylink/translations/fr.json +++ b/homeassistant/components/somfy_mylink/translations/fr.json @@ -1,13 +1,22 @@ { "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + }, "error": { + "cannot_connect": "\u00c9chec de la connexion ", + "invalid_auth": "Authentification invalide ", "unknown": "Erreur inattendue" }, + "flow_title": "Somfy MyLink {mac} ( {ip} )", "step": { "user": { "data": { - "host": "H\u00f4te" - } + "host": "H\u00f4te", + "port": "Port", + "system_id": "ID syst\u00e8me" + }, + "description": "L'ID syst\u00e8me peut \u00eatre obtenu dans l'application MyLink sous Int\u00e9gration en s\u00e9lectionnant n'importe quel service non cloud." } } }, @@ -20,6 +29,7 @@ "data": { "reverse": "La couverture est invers\u00e9e" }, + "description": "Configurer les options pour \u00ab {entity_id} \u00bb", "title": "Configurez une entit\u00e9 sp\u00e9cifique" }, "init": { diff --git a/homeassistant/components/somfy_mylink/translations/ko.json b/homeassistant/components/somfy_mylink/translations/ko.json new file mode 100644 index 00000000000..4d4a78ee1f0 --- /dev/null +++ b/homeassistant/components/somfy_mylink/translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + } + } + }, + "options": { + "abort": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/nl.json b/homeassistant/components/somfy_mylink/translations/nl.json new file mode 100644 index 00000000000..b0ae5c9d3ad --- /dev/null +++ b/homeassistant/components/somfy_mylink/translations/nl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "flow_title": "Somfy MyLink {mac} ({ip})", + "step": { + "user": { + "data": { + "host": "Host", + "port": "Poort", + "system_id": "Systeem-ID" + } + } + } + }, + "options": { + "abort": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "entity_config": { + "description": "Configureer opties voor `{entity_id}`", + "title": "Entiteit configureren" + }, + "init": { + "data": { + "entity_id": "Configureer een specifieke entiteit." + }, + "title": "Configureer MyLink-opties" + } + } + }, + "title": "Somfy MyLink" +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/ru.json b/homeassistant/components/somfy_mylink/translations/ru.json index e4cc7b71712..7c981664335 100644 --- a/homeassistant/components/somfy_mylink/translations/ru.json +++ b/homeassistant/components/somfy_mylink/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "Somfy MyLink {mac} ({ip})", diff --git a/homeassistant/components/sonarr/translations/et.json b/homeassistant/components/sonarr/translations/et.json index 957b3c74eae..c95b2e9dc88 100644 --- a/homeassistant/components/sonarr/translations/et.json +++ b/homeassistant/components/sonarr/translations/et.json @@ -13,7 +13,7 @@ "step": { "reauth_confirm": { "description": "Sonarr-i sidumine tuleb k\u00e4sitsi taastuvastada Sonarr API abil: {host}", - "title": "Autentige uuesti Sonarriga" + "title": "Autendi Sonarriga uuesti" }, "user": { "data": { diff --git a/homeassistant/components/sonarr/translations/it.json b/homeassistant/components/sonarr/translations/it.json index 1a383201ab1..71a4cca729e 100644 --- a/homeassistant/components/sonarr/translations/it.json +++ b/homeassistant/components/sonarr/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", - "reauth_successful": "La riautenticazione ha avuto successo", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" }, "error": { @@ -13,7 +13,7 @@ "step": { "reauth_confirm": { "description": "L'integrazione di Sonarr deve essere nuovamente autenticata manualmente con l'API Sonarr ospitata su: {host}", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" }, "user": { "data": { diff --git a/homeassistant/components/sonarr/translations/ko.json b/homeassistant/components/sonarr/translations/ko.json index fe650991778..17e3592d509 100644 --- a/homeassistant/components/sonarr/translations/ko.json +++ b/homeassistant/components/sonarr/translations/ko.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { @@ -10,14 +11,17 @@ }, "flow_title": "Sonarr: {name}", "step": { + "reauth_confirm": { + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" + }, "user": { "data": { "api_key": "API \ud0a4", "base_path": "API \uacbd\ub85c", "host": "\ud638\uc2a4\ud2b8", "port": "\ud3ec\ud2b8", - "ssl": "Sonarr \ub294 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4", - "verify_ssl": "Sonarr \ub294 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" } } } diff --git a/homeassistant/components/sonarr/translations/nl.json b/homeassistant/components/sonarr/translations/nl.json index 58db7f57dd4..08ef9bb2ece 100644 --- a/homeassistant/components/sonarr/translations/nl.json +++ b/homeassistant/components/sonarr/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Service is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol", "unknown": "Onverwachte fout" }, "error": { @@ -9,6 +10,10 @@ "invalid_auth": "Ongeldige authenticatie" }, "step": { + "reauth_confirm": { + "description": "De Sonarr-integratie moet handmatig opnieuw worden geverifieerd met de Sonarr-API die wordt gehost op: {host}", + "title": "Verifieer de integratie opnieuw" + }, "user": { "data": { "api_key": "API-sleutel", diff --git a/homeassistant/components/sonarr/translations/ru.json b/homeassistant/components/sonarr/translations/ru.json index 1b6345d4563..75d23cd3ec0 100644 --- a/homeassistant/components/sonarr/translations/ru.json +++ b/homeassistant/components/sonarr/translations/ru.json @@ -7,7 +7,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "Sonarr: {name}", "step": { diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 9acbedd11c7..aaa9302cac2 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -114,7 +114,6 @@ class SongpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if "videoScreen" in service_types: return self.async_abort(reason="not_songpal_device") - # pylint: disable=no-member self.context["title_placeholders"] = { CONF_NAME: friendly_name, CONF_HOST: parsed_url.hostname, diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index da397b3e5e7..63d5745da21 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -1,4 +1,20 @@ """Const for Sonos.""" +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_COMPOSER, + MEDIA_CLASS_CONTRIBUTING_ARTIST, + MEDIA_CLASS_GENRE, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_TRACK, + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_COMPOSER, + MEDIA_TYPE_CONTRIBUTING_ARTIST, + MEDIA_TYPE_GENRE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TRACK, +) DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" @@ -10,3 +26,98 @@ SONOS_GENRE = "genres" SONOS_ALBUM_ARTIST = "album_artists" SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" + +EXPANDABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_COMPOSER, + MEDIA_TYPE_GENRE, + MEDIA_TYPE_PLAYLIST, + SONOS_ALBUM, + SONOS_ALBUM_ARTIST, + SONOS_ARTIST, + SONOS_GENRE, + SONOS_COMPOSER, + SONOS_PLAYLISTS, +] + +SONOS_TO_MEDIA_CLASSES = { + SONOS_ALBUM: MEDIA_CLASS_ALBUM, + SONOS_ALBUM_ARTIST: MEDIA_CLASS_ARTIST, + SONOS_ARTIST: MEDIA_CLASS_CONTRIBUTING_ARTIST, + SONOS_COMPOSER: MEDIA_CLASS_COMPOSER, + SONOS_GENRE: MEDIA_CLASS_GENRE, + SONOS_PLAYLISTS: MEDIA_CLASS_PLAYLIST, + SONOS_TRACKS: MEDIA_CLASS_TRACK, + "object.container.album.musicAlbum": MEDIA_CLASS_ALBUM, + "object.container.genre.musicGenre": MEDIA_CLASS_PLAYLIST, + "object.container.person.composer": MEDIA_CLASS_PLAYLIST, + "object.container.person.musicArtist": MEDIA_CLASS_ARTIST, + "object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST, + "object.container.playlistContainer": MEDIA_CLASS_PLAYLIST, + "object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK, +} + +SONOS_TO_MEDIA_TYPES = { + SONOS_ALBUM: MEDIA_TYPE_ALBUM, + SONOS_ALBUM_ARTIST: MEDIA_TYPE_ARTIST, + SONOS_ARTIST: MEDIA_TYPE_CONTRIBUTING_ARTIST, + SONOS_COMPOSER: MEDIA_TYPE_COMPOSER, + SONOS_GENRE: MEDIA_TYPE_GENRE, + SONOS_PLAYLISTS: MEDIA_TYPE_PLAYLIST, + SONOS_TRACKS: MEDIA_TYPE_TRACK, + "object.container.album.musicAlbum": MEDIA_TYPE_ALBUM, + "object.container.genre.musicGenre": MEDIA_TYPE_PLAYLIST, + "object.container.person.composer": MEDIA_TYPE_PLAYLIST, + "object.container.person.musicArtist": MEDIA_TYPE_ARTIST, + "object.container.playlistContainer.sameArtist": MEDIA_TYPE_ARTIST, + "object.container.playlistContainer": MEDIA_TYPE_PLAYLIST, + "object.item.audioItem.musicTrack": MEDIA_TYPE_TRACK, +} + +MEDIA_TYPES_TO_SONOS = { + MEDIA_TYPE_ALBUM: SONOS_ALBUM, + MEDIA_TYPE_ARTIST: SONOS_ALBUM_ARTIST, + MEDIA_TYPE_CONTRIBUTING_ARTIST: SONOS_ARTIST, + MEDIA_TYPE_COMPOSER: SONOS_COMPOSER, + MEDIA_TYPE_GENRE: SONOS_GENRE, + MEDIA_TYPE_PLAYLIST: SONOS_PLAYLISTS, + MEDIA_TYPE_TRACK: SONOS_TRACKS, +} + +SONOS_TYPES_MAPPING = { + "A:ALBUM": SONOS_ALBUM, + "A:ALBUMARTIST": SONOS_ALBUM_ARTIST, + "A:ARTIST": SONOS_ARTIST, + "A:COMPOSER": SONOS_COMPOSER, + "A:GENRE": SONOS_GENRE, + "A:PLAYLISTS": SONOS_PLAYLISTS, + "A:TRACKS": SONOS_TRACKS, + "object.container.album.musicAlbum": SONOS_ALBUM, + "object.container.genre.musicGenre": SONOS_GENRE, + "object.container.person.composer": SONOS_COMPOSER, + "object.container.person.musicArtist": SONOS_ALBUM_ARTIST, + "object.container.playlistContainer.sameArtist": SONOS_ARTIST, + "object.container.playlistContainer": SONOS_PLAYLISTS, + "object.item.audioItem.musicTrack": SONOS_TRACKS, +} + +LIBRARY_TITLES_MAPPING = { + "A:ALBUM": "Albums", + "A:ALBUMARTIST": "Artists", + "A:ARTIST": "Contributing Artists", + "A:COMPOSER": "Composers", + "A:GENRE": "Genres", + "A:PLAYLISTS": "Playlists", + "A:TRACKS": "Tracks", +} + +PLAYABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_COMPOSER, + MEDIA_TYPE_CONTRIBUTING_ARTIST, + MEDIA_TYPE_GENRE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TRACK, +] diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py new file mode 100644 index 00000000000..3d5a1230bcb --- /dev/null +++ b/homeassistant/components/sonos/exception.py @@ -0,0 +1,6 @@ +"""Sonos specific exceptions.""" +from homeassistant.components.media_player.errors import BrowseError + + +class UnknownMediaType(BrowseError): + """Unknown media type.""" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 1852f9c3849..e208a0e7a32 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": ["pysonos==0.0.37"], + "requirements": ["pysonos==0.0.40"], "after_dependencies": ["plex"], "ssdp": [ { diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py new file mode 100644 index 00000000000..6b6c927ca1c --- /dev/null +++ b/homeassistant/components/sonos/media_browser.py @@ -0,0 +1,218 @@ +"""Support for media browsing.""" +import logging +import urllib.parse + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_TYPE_ALBUM, +) +from homeassistant.components.media_player.errors import BrowseError + +from .const import ( + EXPANDABLE_MEDIA_TYPES, + LIBRARY_TITLES_MAPPING, + MEDIA_TYPES_TO_SONOS, + PLAYABLE_MEDIA_TYPES, + SONOS_ALBUM, + SONOS_ALBUM_ARTIST, + SONOS_GENRE, + SONOS_TO_MEDIA_CLASSES, + SONOS_TO_MEDIA_TYPES, + SONOS_TRACKS, + SONOS_TYPES_MAPPING, +) +from .exception import UnknownMediaType + +_LOGGER = logging.getLogger(__name__) + + +def build_item_response(media_library, payload, get_thumbnail_url=None): + """Create response payload for the provided media query.""" + if payload["search_type"] == MEDIA_TYPE_ALBUM and payload["idstring"].startswith( + ("A:GENRE", "A:COMPOSER") + ): + payload["idstring"] = "A:ALBUMARTIST/" + "/".join( + payload["idstring"].split("/")[2:] + ) + + media = media_library.browse_by_idstring( + MEDIA_TYPES_TO_SONOS[payload["search_type"]], + payload["idstring"], + full_album_art_uri=True, + max_items=0, + ) + + if media is None: + return + + thumbnail = None + title = None + + # Fetch album info for titles and thumbnails + # Can't be extracted from track info + if ( + payload["search_type"] == MEDIA_TYPE_ALBUM + and media[0].item_class == "object.item.audioItem.musicTrack" + ): + item = get_media(media_library, payload["idstring"], SONOS_ALBUM_ARTIST) + title = getattr(item, "title", None) + thumbnail = get_thumbnail_url(SONOS_ALBUM_ARTIST, payload["idstring"]) + + if not title: + try: + title = urllib.parse.unquote(payload["idstring"].split("/")[1]) + except IndexError: + title = LIBRARY_TITLES_MAPPING[payload["idstring"]] + + try: + media_class = SONOS_TO_MEDIA_CLASSES[ + MEDIA_TYPES_TO_SONOS[payload["search_type"]] + ] + except KeyError: + _LOGGER.debug("Unknown media type received %s", payload["search_type"]) + return None + + children = [] + for item in media: + try: + children.append(item_payload(item, get_thumbnail_url)) + except UnknownMediaType: + pass + + return BrowseMedia( + title=title, + thumbnail=thumbnail, + media_class=media_class, + media_content_id=payload["idstring"], + media_content_type=payload["search_type"], + children=children, + can_play=can_play(payload["search_type"]), + can_expand=can_expand(payload["search_type"]), + ) + + +def item_payload(item, get_thumbnail_url=None): + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + media_type = get_media_type(item) + try: + media_class = SONOS_TO_MEDIA_CLASSES[media_type] + except KeyError as err: + _LOGGER.debug("Unknown media type received %s", media_type) + raise UnknownMediaType from err + + content_id = get_content_id(item) + thumbnail = None + if getattr(item, "album_art_uri", None): + thumbnail = get_thumbnail_url(media_class, content_id) + + return BrowseMedia( + title=item.title, + thumbnail=thumbnail, + media_class=media_class, + media_content_id=content_id, + media_content_type=SONOS_TO_MEDIA_TYPES[media_type], + can_play=can_play(item.item_class), + can_expand=can_expand(item), + ) + + +def library_payload(media_library, get_thumbnail_url=None): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + if not media_library.browse_by_idstring( + "tracks", + "", + max_items=1, + ): + raise BrowseError("Local library not found") + + children = [] + for item in media_library.browse(): + try: + children.append(item_payload(item, get_thumbnail_url)) + except UnknownMediaType: + pass + + return BrowseMedia( + title="Music Library", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="library", + media_content_type="library", + can_play=False, + can_expand=True, + children=children, + ) + + +def get_media_type(item): + """Extract media type of item.""" + if item.item_class == "object.item.audioItem.musicTrack": + return SONOS_TRACKS + + if ( + item.item_class == "object.container.album.musicAlbum" + and SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0]) + in [ + SONOS_ALBUM_ARTIST, + SONOS_GENRE, + ] + ): + return SONOS_TYPES_MAPPING[item.item_class] + + return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class) + + +def can_play(item): + """ + Test if playable. + + Used by async_browse_media. + """ + return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES + + +def can_expand(item): + """ + Test if expandable. + + Used by async_browse_media. + """ + if isinstance(item, str): + return SONOS_TYPES_MAPPING.get(item) in EXPANDABLE_MEDIA_TYPES + + if SONOS_TO_MEDIA_TYPES.get(item.item_class) in EXPANDABLE_MEDIA_TYPES: + return True + + return SONOS_TYPES_MAPPING.get(item.item_id) in EXPANDABLE_MEDIA_TYPES + + +def get_content_id(item): + """Extract content id or uri.""" + if item.item_class == "object.item.audioItem.musicTrack": + return item.get_uri() + return item.item_id + + +def get_media(media_library, item_id, search_type): + """Fetch media/album.""" + search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type) + + if not item_id.startswith("A:ALBUM") and search_type == SONOS_ALBUM: + item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:]) + + for item in media_library.browse_by_idstring( + search_type, + "/".join(item_id.split("/")[:-1]), + full_album_art_uri=True, + max_items=0, + ): + if item.item_id == item_id: + return item diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 9d89bdf68f8..e6ee45e7a57 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -10,33 +10,22 @@ import async_timeout import pysonos from pysonos import alarms from pysonos.core import ( + MUSIC_SRC_LINE_IN, + MUSIC_SRC_RADIO, + MUSIC_SRC_TV, PLAY_MODE_BY_MEANING, PLAY_MODES, - PLAYING_LINE_IN, - PLAYING_RADIO, - PLAYING_TV, ) from pysonos.exceptions import SoCoException, SoCoUPnPException import pysonos.music_library import pysonos.snapshot import voluptuous as vol -from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, - MEDIA_CLASS_ALBUM, - MEDIA_CLASS_ARTIST, - MEDIA_CLASS_COMPOSER, - MEDIA_CLASS_CONTRIBUTING_ARTIST, - MEDIA_CLASS_DIRECTORY, - MEDIA_CLASS_GENRE, - MEDIA_CLASS_PLAYLIST, - MEDIA_CLASS_TRACK, MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST, - MEDIA_TYPE_COMPOSER, - MEDIA_TYPE_CONTRIBUTING_ARTIST, - MEDIA_TYPE_GENRE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TRACK, @@ -71,20 +60,17 @@ from homeassistant.const import ( from homeassistant.core import ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_platform, service import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.network import is_internal_request from homeassistant.util.dt import utcnow from . import CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR from .const import ( DATA_SONOS, DOMAIN as SONOS_DOMAIN, - SONOS_ALBUM, - SONOS_ALBUM_ARTIST, - SONOS_ARTIST, - SONOS_COMPOSER, - SONOS_GENRE, - SONOS_PLAYLISTS, - SONOS_TRACKS, + MEDIA_TYPES_TO_SONOS, + PLAYABLE_MEDIA_TYPES, ) +from .media_browser import build_item_response, get_media, library_payload _LOGGER = logging.getLogger(__name__) @@ -111,101 +97,6 @@ SUPPORT_SONOS = ( SOURCE_LINEIN = "Line-in" SOURCE_TV = "TV" -EXPANDABLE_MEDIA_TYPES = [ - MEDIA_TYPE_ALBUM, - MEDIA_TYPE_ARTIST, - MEDIA_TYPE_COMPOSER, - MEDIA_TYPE_GENRE, - MEDIA_TYPE_PLAYLIST, - SONOS_ALBUM, - SONOS_ALBUM_ARTIST, - SONOS_ARTIST, - SONOS_GENRE, - SONOS_COMPOSER, - SONOS_PLAYLISTS, -] - -SONOS_TO_MEDIA_CLASSES = { - SONOS_ALBUM: MEDIA_CLASS_ALBUM, - SONOS_ALBUM_ARTIST: MEDIA_CLASS_ARTIST, - SONOS_ARTIST: MEDIA_CLASS_CONTRIBUTING_ARTIST, - SONOS_COMPOSER: MEDIA_CLASS_COMPOSER, - SONOS_GENRE: MEDIA_CLASS_GENRE, - SONOS_PLAYLISTS: MEDIA_CLASS_PLAYLIST, - SONOS_TRACKS: MEDIA_CLASS_TRACK, - "object.container.album.musicAlbum": MEDIA_CLASS_ALBUM, - "object.container.genre.musicGenre": MEDIA_CLASS_PLAYLIST, - "object.container.person.composer": MEDIA_CLASS_PLAYLIST, - "object.container.person.musicArtist": MEDIA_CLASS_ARTIST, - "object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST, - "object.container.playlistContainer": MEDIA_CLASS_PLAYLIST, - "object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK, -} - -SONOS_TO_MEDIA_TYPES = { - SONOS_ALBUM: MEDIA_TYPE_ALBUM, - SONOS_ALBUM_ARTIST: MEDIA_TYPE_ARTIST, - SONOS_ARTIST: MEDIA_TYPE_CONTRIBUTING_ARTIST, - SONOS_COMPOSER: MEDIA_TYPE_COMPOSER, - SONOS_GENRE: MEDIA_TYPE_GENRE, - SONOS_PLAYLISTS: MEDIA_TYPE_PLAYLIST, - SONOS_TRACKS: MEDIA_TYPE_TRACK, - "object.container.album.musicAlbum": MEDIA_TYPE_ALBUM, - "object.container.genre.musicGenre": MEDIA_TYPE_PLAYLIST, - "object.container.person.composer": MEDIA_TYPE_PLAYLIST, - "object.container.person.musicArtist": MEDIA_TYPE_ARTIST, - "object.container.playlistContainer.sameArtist": MEDIA_TYPE_ARTIST, - "object.container.playlistContainer": MEDIA_TYPE_PLAYLIST, - "object.item.audioItem.musicTrack": MEDIA_TYPE_TRACK, -} - -MEDIA_TYPES_TO_SONOS = { - MEDIA_TYPE_ALBUM: SONOS_ALBUM, - MEDIA_TYPE_ARTIST: SONOS_ALBUM_ARTIST, - MEDIA_TYPE_CONTRIBUTING_ARTIST: SONOS_ARTIST, - MEDIA_TYPE_COMPOSER: SONOS_COMPOSER, - MEDIA_TYPE_GENRE: SONOS_GENRE, - MEDIA_TYPE_PLAYLIST: SONOS_PLAYLISTS, - MEDIA_TYPE_TRACK: SONOS_TRACKS, -} - -SONOS_TYPES_MAPPING = { - "A:ALBUM": SONOS_ALBUM, - "A:ALBUMARTIST": SONOS_ALBUM_ARTIST, - "A:ARTIST": SONOS_ARTIST, - "A:COMPOSER": SONOS_COMPOSER, - "A:GENRE": SONOS_GENRE, - "A:PLAYLISTS": SONOS_PLAYLISTS, - "A:TRACKS": SONOS_TRACKS, - "object.container.album.musicAlbum": SONOS_ALBUM, - "object.container.genre.musicGenre": SONOS_GENRE, - "object.container.person.composer": SONOS_COMPOSER, - "object.container.person.musicArtist": SONOS_ALBUM_ARTIST, - "object.container.playlistContainer.sameArtist": SONOS_ARTIST, - "object.container.playlistContainer": SONOS_PLAYLISTS, - "object.item.audioItem.musicTrack": SONOS_TRACKS, -} - -LIBRARY_TITLES_MAPPING = { - "A:ALBUM": "Albums", - "A:ALBUMARTIST": "Artists", - "A:ARTIST": "Contributing Artists", - "A:COMPOSER": "Composers", - "A:GENRE": "Genres", - "A:PLAYLISTS": "Playlists", - "A:TRACKS": "Tracks", -} - -PLAYABLE_MEDIA_TYPES = [ - MEDIA_TYPE_ALBUM, - MEDIA_TYPE_ARTIST, - MEDIA_TYPE_COMPOSER, - MEDIA_TYPE_CONTRIBUTING_ARTIST, - MEDIA_TYPE_GENRE, - MEDIA_TYPE_PLAYLIST, - MEDIA_TYPE_TRACK, -] - REPEAT_TO_SONOS = { REPEAT_MODE_OFF: False, REPEAT_MODE_ALL: True, @@ -244,10 +135,6 @@ ATTR_STATUS_LIGHT = "status_light" UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} -class UnknownMediaType(BrowseError): - """Unknown media type.""" - - class SonosData: """Storage class for platform global data.""" @@ -589,6 +476,7 @@ class SonosEntity(MediaPlayerEntity): "sw_version": self._sw_version, "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, "manufacturer": "Sonos", + "suggested_area": self._name, } @property @@ -759,11 +647,12 @@ class SonosEntity(MediaPlayerEntity): self._status = new_status track_uri = variables["current_track_uri"] if variables else None - whats_playing = self.soco.whats_playing(track_uri) - if whats_playing == PLAYING_TV: + music_source = self.soco.music_source_from_uri(track_uri) + + if music_source == MUSIC_SRC_TV: self.update_media_linein(SOURCE_TV) - elif whats_playing == PLAYING_LINE_IN: + elif music_source == MUSIC_SRC_LINE_IN: self.update_media_linein(SOURCE_LINEIN) else: track_info = self.soco.get_current_track_info() @@ -775,7 +664,7 @@ class SonosEntity(MediaPlayerEntity): self._media_album_name = track_info.get("album") self._media_title = track_info.get("title") - if whats_playing == PLAYING_RADIO: + if music_source == MUSIC_SRC_RADIO: self.update_media_radio(variables, track_info) else: self.update_media_music(update_position, track_info) @@ -816,7 +705,7 @@ class SonosEntity(MediaPlayerEntity): uri_meta_data, pysonos.data_structures.DidlAudioBroadcast ) and ( self.state != STATE_PLAYING - or self.soco.is_radio_uri(self._media_title) + or self.soco.music_source_from_uri(self._media_title) == MUSIC_SRC_RADIO or self._media_title in self._uri ): self._media_title = uri_meta_data.title @@ -1117,7 +1006,7 @@ class SonosEntity(MediaPlayerEntity): if len(fav) == 1: src = fav.pop() uri = src.reference.get_uri() - if self.soco.is_radio_uri(uri): + if self.soco.music_source_from_uri(uri) == MUSIC_SRC_RADIO: self.soco.play_uri(uri, title=source) else: self.soco.clear_queue() @@ -1201,8 +1090,8 @@ class SonosEntity(MediaPlayerEntity): elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): if kwargs.get(ATTR_MEDIA_ENQUEUE): try: - if self.soco.is_spotify_uri(media_id): - self.soco.add_spotify_uri_to_queue(media_id) + if self.soco.is_service_uri(media_id): + self.soco.add_service_uri_to_queue(media_id) else: self.soco.add_uri_to_queue(media_id) except SoCoUPnPException: @@ -1213,9 +1102,9 @@ class SonosEntity(MediaPlayerEntity): media_id, ) else: - if self.soco.is_spotify_uri(media_id): + if self.soco.is_service_uri(media_id): self.soco.clear_queue() - self.soco.add_spotify_uri_to_queue(media_id) + self.soco.add_service_uri_to_queue(media_id) self.soco.play_from_queue(0) else: self.soco.play_uri(media_id) @@ -1489,11 +1378,51 @@ class SonosEntity(MediaPlayerEntity): return attributes + async def async_get_browse_image( + self, media_content_type, media_content_id, media_image_id=None + ): + """Fetch media browser image to serve via proxy.""" + if ( + media_content_type in [MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST] + and media_content_id + ): + item = await self.hass.async_add_executor_job( + get_media, + self._media_library, + media_content_id, + MEDIA_TYPES_TO_SONOS[media_content_type], + ) + image_url = getattr(item, "album_art_uri", None) + if image_url: + result = await self._async_fetch_image(image_url) + return result + + return (None, None) + async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" + is_internal = is_internal_request(self.hass) + + def _get_thumbnail_url( + media_content_type, media_content_id, media_image_id=None + ): + if is_internal: + item = get_media( + self._media_library, + media_content_id, + media_content_type, + ) + return getattr(item, "album_art_uri", None) + + return self.get_browse_image_url( + media_content_type, + urllib.parse.quote_plus(media_content_id), + media_image_id, + ) + if media_content_type in [None, "library"]: return await self.hass.async_add_executor_job( - library_payload, self._media_library + library_payload, self._media_library, _get_thumbnail_url ) payload = { @@ -1501,195 +1430,10 @@ class SonosEntity(MediaPlayerEntity): "idstring": media_content_id, } response = await self.hass.async_add_executor_job( - build_item_response, self._media_library, payload + build_item_response, self._media_library, payload, _get_thumbnail_url ) if response is None: raise BrowseError( f"Media not found: {media_content_type} / {media_content_id}" ) return response - - -def build_item_response(media_library, payload): - """Create response payload for the provided media query.""" - if payload["search_type"] == MEDIA_TYPE_ALBUM and payload["idstring"].startswith( - ("A:GENRE", "A:COMPOSER") - ): - payload["idstring"] = "A:ALBUMARTIST/" + "/".join( - payload["idstring"].split("/")[2:] - ) - - media = media_library.browse_by_idstring( - MEDIA_TYPES_TO_SONOS[payload["search_type"]], - payload["idstring"], - full_album_art_uri=True, - max_items=0, - ) - - if media is None: - return - - thumbnail = None - title = None - - # Fetch album info for titles and thumbnails - # Can't be extracted from track info - if ( - payload["search_type"] == MEDIA_TYPE_ALBUM - and media[0].item_class == "object.item.audioItem.musicTrack" - ): - item = get_media(media_library, payload["idstring"], SONOS_ALBUM_ARTIST) - title = getattr(item, "title", None) - thumbnail = getattr(item, "album_art_uri", media[0].album_art_uri) - - if not title: - try: - title = urllib.parse.unquote(payload["idstring"].split("/")[1]) - except IndexError: - title = LIBRARY_TITLES_MAPPING[payload["idstring"]] - - try: - media_class = SONOS_TO_MEDIA_CLASSES[ - MEDIA_TYPES_TO_SONOS[payload["search_type"]] - ] - except KeyError: - _LOGGER.debug("Unknown media type received %s", payload["search_type"]) - return None - - children = [] - for item in media: - try: - children.append(item_payload(item)) - except UnknownMediaType: - pass - - return BrowseMedia( - title=title, - thumbnail=thumbnail, - media_class=media_class, - media_content_id=payload["idstring"], - media_content_type=payload["search_type"], - children=children, - can_play=can_play(payload["search_type"]), - can_expand=can_expand(payload["search_type"]), - ) - - -def item_payload(item): - """ - Create response payload for a single media item. - - Used by async_browse_media. - """ - media_type = get_media_type(item) - try: - media_class = SONOS_TO_MEDIA_CLASSES[media_type] - except KeyError as err: - _LOGGER.debug("Unknown media type received %s", media_type) - raise UnknownMediaType from err - return BrowseMedia( - title=item.title, - thumbnail=getattr(item, "album_art_uri", None), - media_class=media_class, - media_content_id=get_content_id(item), - media_content_type=SONOS_TO_MEDIA_TYPES[media_type], - can_play=can_play(item.item_class), - can_expand=can_expand(item), - ) - - -def library_payload(media_library): - """ - Create response payload to describe contents of a specific library. - - Used by async_browse_media. - """ - if not media_library.browse_by_idstring( - "tracks", - "", - max_items=1, - ): - raise BrowseError("Local library not found") - - children = [] - for item in media_library.browse(): - try: - children.append(item_payload(item)) - except UnknownMediaType: - pass - - return BrowseMedia( - title="Music Library", - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id="library", - media_content_type="library", - can_play=False, - can_expand=True, - children=children, - ) - - -def get_media_type(item): - """Extract media type of item.""" - if item.item_class == "object.item.audioItem.musicTrack": - return SONOS_TRACKS - - if ( - item.item_class == "object.container.album.musicAlbum" - and SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0]) - in [ - SONOS_ALBUM_ARTIST, - SONOS_GENRE, - ] - ): - return SONOS_TYPES_MAPPING[item.item_class] - - return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class) - - -def can_play(item): - """ - Test if playable. - - Used by async_browse_media. - """ - return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES - - -def can_expand(item): - """ - Test if expandable. - - Used by async_browse_media. - """ - if isinstance(item, str): - return SONOS_TYPES_MAPPING.get(item) in EXPANDABLE_MEDIA_TYPES - - if SONOS_TO_MEDIA_TYPES.get(item.item_class) in EXPANDABLE_MEDIA_TYPES: - return True - - return SONOS_TYPES_MAPPING.get(item.item_id) in EXPANDABLE_MEDIA_TYPES - - -def get_content_id(item): - """Extract content id or uri.""" - if item.item_class == "object.item.audioItem.musicTrack": - return item.get_uri() - return item.item_id - - -def get_media(media_library, item_id, search_type): - """Fetch media/album.""" - search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type) - - if not item_id.startswith("A:ALBUM") and search_type == SONOS_ALBUM: - item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:]) - - for item in media_library.browse_by_idstring( - search_type, - "/".join(item_id.split("/")[:-1]), - full_album_art_uri=True, - max_items=0, - ): - if item.item_id == item_id: - return item diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 8a35e9a7790..99b430e4680 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -1,9 +1,15 @@ join: + name: Join group description: Group player together. fields: master: - description: Entity ID of the player that should become the coordinator of the group. + description: + Entity ID of the player that should become the coordinator of the group. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player entity_id: description: Name(s) of entities that will join the master. example: "media_player.living_room_sonos" @@ -13,6 +19,7 @@ join: domain: media_player unjoin: + name: Unjoin group description: Unjoin the player from a group. fields: entity_id: @@ -24,6 +31,7 @@ unjoin: domain: media_player snapshot: + name: Snapshot description: Take a snapshot of the media player. fields: entity_id: @@ -38,6 +46,7 @@ snapshot: example: "true" restore: + name: Restore description: Restore a snapshot of the media player. fields: entity_id: @@ -52,6 +61,7 @@ restore: example: "true" set_sleep_timer: + name: Set timer description: Set a Sonos timer. fields: entity_id: @@ -64,8 +74,16 @@ set_sleep_timer: sleep_time: description: Number of seconds to set the timer. example: "900" + selector: + number: + min: 0 + max: 3600 + step: 1 + unit_of_measurement: seconds + mode: slider clear_sleep_timer: + name: Clear timer description: Clear a Sonos timer. fields: entity_id: @@ -77,6 +95,7 @@ clear_sleep_timer: domain: media_player set_option: + name: Set option description: Set Sonos sound options. fields: entity_id: @@ -89,15 +108,22 @@ set_option: night_sound: description: Enable Night Sound mode example: "true" + selector: + boolean: speech_enhance: description: Enable Speech Enhancement mode example: "true" + selector: + boolean: status_light: description: Enable Status (LED) Light example: "true" + selector: + boolean: play_queue: - description: Starts playing the queue from the first item. + name: Play queue + description: Start playing the queue from the first item. fields: entity_id: description: Name(s) of entities that will start playing. @@ -109,8 +135,14 @@ play_queue: queue_position: description: Position of the song in the queue to start playing from. example: "0" + selector: + number: + min: 0 + max: 100000000 + mode: box remove_from_queue: + name: Remove from queue description: Removes an item from the queue. fields: entity_id: @@ -123,8 +155,14 @@ remove_from_queue: queue_position: description: Position in the queue to remove. example: "0" + selector: + number: + min: 0 + max: 100000000 + mode: box update_alarm: + name: Update alarm description: Updates an alarm with new time and volume settings. fields: alarm_id: diff --git a/homeassistant/components/sonos/translations/ko.json b/homeassistant/components/sonos/translations/ko.json index c92b50a0f83..ba85f8df170 100644 --- a/homeassistant/components/sonos/translations/ko.json +++ b/homeassistant/components/sonos/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Sonos \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Sonos \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/speedtestdotnet/translations/ko.json b/homeassistant/components/speedtestdotnet/translations/ko.json index ede64fa0531..2951d72d201 100644 --- a/homeassistant/components/speedtestdotnet/translations/ko.json +++ b/homeassistant/components/speedtestdotnet/translations/ko.json @@ -1,11 +1,12 @@ { "config": { "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", "wrong_server_id": "\uc11c\ubc84 ID \uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { - "description": "SpeedTest \ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?" } } }, diff --git a/homeassistant/components/speedtestdotnet/translations/nl.json b/homeassistant/components/speedtestdotnet/translations/nl.json index 0c0c184b5fe..1fe99195f7a 100644 --- a/homeassistant/components/speedtestdotnet/translations/nl.json +++ b/homeassistant/components/speedtestdotnet/translations/nl.json @@ -3,6 +3,11 @@ "abort": { "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", "wrong_server_id": "Server-ID is niet geldig" + }, + "step": { + "user": { + "description": "Wil je beginnen met instellen?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/spider/translations/ko.json b/homeassistant/components/spider/translations/ko.json index 1f08b96ee10..9e9ed5b0f30 100644 --- a/homeassistant/components/spider/translations/ko.json +++ b/homeassistant/components/spider/translations/ko.json @@ -1,8 +1,19 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, "error": { - "invalid_auth": "\uc798\ubabb\ub41c \uc778\uc99d", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc5d0\ub7ec" + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/spider/translations/nl.json b/homeassistant/components/spider/translations/nl.json index f0b4ddf59a9..bc7683ac0a4 100644 --- a/homeassistant/components/spider/translations/nl.json +++ b/homeassistant/components/spider/translations/nl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, "error": { "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/spider/translations/ru.json b/homeassistant/components/spider/translations/ru.json index 983f2b94361..1b1a175cce5 100644 --- a/homeassistant/components/spider/translations/ru.json +++ b/homeassistant/components/spider/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." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index ac6e101d4fe..afad75f0f39 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -63,7 +63,6 @@ class SpotifyFlowHandler( if entry: self.entry = entry - assert self.hass persistent_notification.async_create( self.hass, f"Spotify integration for account {entry['id']} needs to be re-authenticated. Please go to the integrations page to re-configure it.", @@ -85,7 +84,6 @@ class SpotifyFlowHandler( errors={}, ) - assert self.hass persistent_notification.async_dismiss(self.hass, "spotify_reauth") return await self.async_step_pick_implementation( diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 74df79c4d78..f775e5df85d 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -10,7 +10,7 @@ } }, "abort": { - "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_timeout": "Timeout generating authorize URL.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." diff --git a/homeassistant/components/spotify/translations/cs.json b/homeassistant/components/spotify/translations/cs.json index f8f122e63e2..69cd1b1623a 100644 --- a/homeassistant/components/spotify/translations/cs.json +++ b/homeassistant/components/spotify/translations/cs.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el.", "missing_configuration": "Integrace Spotify nen\u00ed nastavena. Postupujte podle dokumentace.", "no_url_available": "Nen\u00ed k dispozici \u017e\u00e1dn\u00e1 adresa URL. Informace o t\u00e9to chyb\u011b naleznete [v sekci n\u00e1pov\u011bdy]({docs_url})" }, diff --git a/homeassistant/components/spotify/translations/en.json b/homeassistant/components/spotify/translations/en.json index 73ea219105b..7136e5a8e71 100644 --- a/homeassistant/components/spotify/translations/en.json +++ b/homeassistant/components/spotify/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_timeout": "Timeout generating authorize URL.", "missing_configuration": "The Spotify integration 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_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." diff --git a/homeassistant/components/spotify/translations/et.json b/homeassistant/components/spotify/translations/et.json index 01583d1b0f0..c5cee44acca 100644 --- a/homeassistant/components/spotify/translations/et.json +++ b/homeassistant/components/spotify/translations/et.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Kinnituse URLi ajal\u00f5pp", - "missing_configuration": "Spotify sidumine pole h\u00e4\u00e4lestatud. Palun j\u00e4rgige dokumentatsiooni.", + "missing_configuration": "Spotify sidumine pole h\u00e4\u00e4lestatud. Palun j\u00e4rgi dokumentatsiooni.", "no_url_available": "URL pole saadaval. Rohkem teavet [check the help section]({docs_url})", "reauth_account_mismatch": "Spotify konto mida autenditi ei vasta kontole mis vajas uuesti autentimist." }, diff --git a/homeassistant/components/spotify/translations/fr.json b/homeassistant/components/spotify/translations/fr.json index f4f6566e88d..d6b5838feb5 100644 --- a/homeassistant/components/spotify/translations/fr.json +++ b/homeassistant/components/spotify/translations/fr.json @@ -18,5 +18,10 @@ "title": "R\u00e9-authentifier avec Spotify" } } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Point de terminaison de l'API Spotify accessible" + } } } \ No newline at end of file diff --git a/homeassistant/components/spotify/translations/it.json b/homeassistant/components/spotify/translations/it.json index 6911d38be00..28d821c81f1 100644 --- a/homeassistant/components/spotify/translations/it.json +++ b/homeassistant/components/spotify/translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione", + "authorize_url_timeout": "Tempo scaduto nella generazione dell'URL di autorizzazione.", "missing_configuration": "L'integrazione di Spotify non \u00e8 configurata. 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_account_mismatch": "L'account Spotify con cui si \u00e8 autenticati non corrisponde all'account necessario per la ri-autenticazione." @@ -15,7 +15,7 @@ }, "reauth_confirm": { "description": "L'integrazione di Spotify deve essere nuovamente autenticata con Spotify per l'account: {account}", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" } } }, diff --git a/homeassistant/components/spotify/translations/ko.json b/homeassistant/components/spotify/translations/ko.json index a12162bb4fb..22a338a8d7c 100644 --- a/homeassistant/components/spotify/translations/ko.json +++ b/homeassistant/components/spotify/translations/ko.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "missing_configuration": "Spotify \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", "reauth_account_mismatch": "\uc778\uc99d\ub41c Spotify \uacc4\uc815\uc740 \uc7ac\uc778\uc99d\uc774 \ud544\uc694\ud55c \uacc4\uc815\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, "create_entry": { @@ -15,7 +15,7 @@ }, "reauth_confirm": { "description": "Spotify \ud1b5\ud569\uc740 \uacc4\uc815 {account} \ub300\ud574 Spotify\ub85c \ub2e4\uc2dc \uc778\uc99d\ud574\uc57c\ud569\ub2c8\ub2e4.", - "title": "Spotify\ub85c \uc7ac \uc778\uc99d" + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" } } } diff --git a/homeassistant/components/spotify/translations/nl.json b/homeassistant/components/spotify/translations/nl.json index bdc86919f74..46b18857fe8 100644 --- a/homeassistant/components/spotify/translations/nl.json +++ b/homeassistant/components/spotify/translations/nl.json @@ -15,7 +15,7 @@ }, "reauth_confirm": { "description": "De Spotify integratie moet opnieuw worden geverifieerd met Spotify voor account: {account}", - "title": "Verifieer opnieuw met Spotify" + "title": "Verifieer de integratie opnieuw" } } } diff --git a/homeassistant/components/spotify/translations/no.json b/homeassistant/components/spotify/translations/no.json index 8e2ec3d36c0..54e3ca1f8b4 100644 --- a/homeassistant/components/spotify/translations/no.json +++ b/homeassistant/components/spotify/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", + "authorize_url_timeout": "Tidsavbrudd genererer godkjennelses-URL.", "missing_configuration": "Spotify-integrasjonen er ikke konfigurert. F\u00f8lg dokumentasjonen.", "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", "reauth_account_mismatch": "Spotify-kontoen som er godkjent samsvarer ikke med kontoen som trenger godkjenning p\u00e5 nytt" diff --git a/homeassistant/components/spotify/translations/pl.json b/homeassistant/components/spotify/translations/pl.json index f9e6f429214..52028d4d368 100644 --- a/homeassistant/components/spotify/translations/pl.json +++ b/homeassistant/components/spotify/translations/pl.json @@ -21,7 +21,7 @@ }, "system_health": { "info": { - "api_endpoint_reachable": "Dost\u0119pno\u015b\u0107 punktu ko\u0144cowego API Spotify" + "api_endpoint_reachable": "Punkt ko\u0144cowy Spotify API osi\u0105galny" } } } \ No newline at end of file diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 3b21d32b110..7418eb095da 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,6 +2,6 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.3.22"], + "requirements": ["sqlalchemy==1.3.23"], "codeowners": ["@dgomes"] } diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index f5ed6073104..9edff5f9a2a 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -182,7 +182,6 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # update schema with suggested values from discovery self.data_schema = _base_schema(discovery_info) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}}) return await self.async_step_edit() diff --git a/homeassistant/components/squeezebox/translations/ru.json b/homeassistant/components/squeezebox/translations/ru.json index 789fff313d8..fb07471d116 100644 --- a/homeassistant/components/squeezebox/translations/ru.json +++ b/homeassistant/components/squeezebox/translations/ru.json @@ -6,7 +6,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "no_server_found": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0441\u0435\u0440\u0432\u0435\u0440.", "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/srp_energy/translations/fr.json b/homeassistant/components/srp_energy/translations/fr.json index 0cc85aff649..b9b33cfa930 100644 --- a/homeassistant/components/srp_energy/translations/fr.json +++ b/homeassistant/components/srp_energy/translations/fr.json @@ -1,14 +1,24 @@ { "config": { + "abort": { + "single_instance_allowed": "D\u00e9ja configur\u00e9. Seulement une seule configuration est possible " + }, "error": { + "cannot_connect": "\u00c9chec de la connexion ", + "invalid_account": "L'ID de compte doit \u00eatre un num\u00e9ro \u00e0 9 chiffres", + "invalid_auth": "Authentification invalide ", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "password": "Mot de passe" + "id": "Identifiant de compte", + "is_tou": "Est le plan de temps d'utilisation", + "password": "Mot de passe", + "username": "Nom d'utilisateur " } } } - } + }, + "title": "\u00c9nergie SRP" } \ No newline at end of file diff --git a/homeassistant/components/srp_energy/translations/ko.json b/homeassistant/components/srp_energy/translations/ko.json new file mode 100644 index 00000000000..4b6af62638a --- /dev/null +++ b/homeassistant/components/srp_energy/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/translations/nl.json b/homeassistant/components/srp_energy/translations/nl.json new file mode 100644 index 00000000000..cd06c36b661 --- /dev/null +++ b/homeassistant/components/srp_energy/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_account": "Account-ID moet een 9-cijferig nummer zijn", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + }, + "title": "SRP Energy" +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/translations/ru.json b/homeassistant/components/srp_energy/translations/ru.json index 3fcbace37df..125f3a5addc 100644 --- a/homeassistant/components/srp_energy/translations/ru.json +++ b/homeassistant/components/srp_energy/translations/ru.json @@ -6,7 +6,7 @@ "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_account": "ID \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c 9-\u0437\u043d\u0430\u0447\u043d\u044b\u043c \u0447\u0438\u0441\u043b\u043e\u043c.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f07e88d811a..8cad4a74bf8 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -1,14 +1,16 @@ """The SSDP integration.""" import asyncio from datetime import timedelta -import itertools import logging +from typing import Any, Mapping import aiohttp +from async_upnp_client.search import async_search from defusedxml import ElementTree from netdisco import ssdp, util from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import callback from homeassistant.helpers.event import async_track_time_interval from homeassistant.loader import async_get_ssdp @@ -51,12 +53,6 @@ async def async_setup(hass, config): return True -def _run_ssdp_scans(): - _LOGGER.debug("Scanning") - # Run 3 times as packets can get lost - return itertools.chain.from_iterable([ssdp.scan() for _ in range(3)]) - - class Scanner: """Class to manage SSDP scanning.""" @@ -64,25 +60,38 @@ class Scanner: """Initialize class.""" self.hass = hass self.seen = set() + self._entries = [] self._integration_matchers = integration_matchers self._description_cache = {} + async def _on_ssdp_response(self, data: Mapping[str, Any]) -> None: + """Process an ssdp response.""" + self.async_store_entry( + ssdp.UPNPEntry({key.lower(): item for key, item in data.items()}) + ) + + @callback + def async_store_entry(self, entry): + """Save an entry for later processing.""" + self._entries.append(entry) + async def async_scan(self, _): """Scan for new entries.""" - entries = await self.hass.async_add_executor_job(_run_ssdp_scans) - await self._process_entries(entries) + await async_search(async_callback=self._on_ssdp_response) + await self._process_entries() # We clear the cache after each run. We track discovered entries # so will never need a description twice. self._description_cache.clear() + self._entries.clear() - async def _process_entries(self, entries): + async def _process_entries(self): """Process SSDP entries.""" entries_to_process = [] unseen_locations = set() - for entry in entries: + for entry in self._entries: key = (entry.st, entry.location) if key in self.seen: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index ed20ae9ead6..931119e2398 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": ["defusedxml==0.6.0", "netdisco==2.8.2"], + "requirements": ["defusedxml==0.6.0", "netdisco==2.8.2", "async-upnp-client==0.14.13"], "after_dependencies": ["zeroconf"], "codeowners": [] } diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py index 392dbff9e03..3025a7b4c11 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -2,12 +2,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .account import StarlineAccount from .const import ( - CONF_SCAN_INTERVAL, CONF_SCAN_OBD_INTERVAL, DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_OBD_INTERVAL, diff --git a/homeassistant/components/starline/const.py b/homeassistant/components/starline/const.py index 89ea0873aa1..488a5cb9e0f 100644 --- a/homeassistant/components/starline/const.py +++ b/homeassistant/components/starline/const.py @@ -11,7 +11,6 @@ CONF_APP_SECRET = "app_secret" CONF_MFA_CODE = "mfa_code" CONF_CAPTCHA_CODE = "captcha_code" -CONF_SCAN_INTERVAL = "scan_interval" DEFAULT_SCAN_INTERVAL = 180 # in seconds CONF_SCAN_OBD_INTERVAL = "scan_obd_interval" DEFAULT_SCAN_OBD_INTERVAL = 10800 # 3 hours in seconds diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 56cd8686186..0b158451fb3 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -8,7 +8,6 @@ from .entity import StarlineEntity async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine lock.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] entities = [] for device in account.api.devices.values(): diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c7d1dad4835..2d115c6978d 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -1,46 +1,50 @@ -"""Provide functionality to stream video source.""" +"""Provide functionality to stream video source. + +Components use create_stream with a stream source (e.g. an rtsp url) to create +a new Stream object. Stream manages: + - Background work to fetch and decode a stream + - Desired output formats + - Home Assistant URLs for viewing a stream + - Access tokens for URLs for viewing a stream + +A Stream consists of a background worker, and one or more output formats each +with their own idle timeout managed by the stream component. When an output +format is no longer in use, the stream component will expire it. When there +are no active output formats, the background worker is shut down and access +tokens are expired. Alternatively, a Stream can be configured with keepalive +to always keep workers active. +""" import logging import secrets import threading +import time from types import MappingProxyType -import voluptuous as vol - -from homeassistant.const import CONF_FILENAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.loader import bind_hass from .const import ( ATTR_ENDPOINTS, ATTR_STREAMS, - CONF_DURATION, - CONF_LOOKBACK, - CONF_STREAM_SOURCE, DOMAIN, MAX_SEGMENTS, - SERVICE_RECORD, + OUTPUT_IDLE_TIMEOUT, + STREAM_RESTART_INCREMENT, + STREAM_RESTART_RESET_TIME, ) -from .core import PROVIDERS +from .core import PROVIDERS, IdleTimer from .hls import async_setup_hls _LOGGER = logging.getLogger(__name__) -STREAM_SERVICE_SCHEMA = vol.Schema({vol.Required(CONF_STREAM_SOURCE): cv.string}) -SERVICE_RECORD_SCHEMA = STREAM_SERVICE_SCHEMA.extend( - { - vol.Required(CONF_FILENAME): cv.string, - vol.Optional(CONF_DURATION, default=30): int, - vol.Optional(CONF_LOOKBACK, default=0): int, - } -) +def create_stream(hass, stream_source, options=None): + """Create a stream with the specified identfier based on the source url. - -@bind_hass -def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=None): - """Set up stream with token.""" + The stream_source is typically an rtsp url and options are passed into + pyav / ffmpeg as options. + """ if DOMAIN not in hass.config.components: raise HomeAssistantError("Stream integration is not set up.") @@ -55,25 +59,9 @@ def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=N **options, } - try: - streams = hass.data[DOMAIN][ATTR_STREAMS] - stream = streams.get(stream_source) - if not stream: - stream = Stream(hass, stream_source, options=options, keepalive=keepalive) - streams[stream_source] = stream - else: - # Update keepalive option on existing stream - stream.keepalive = keepalive - - # Add provider - stream.add_provider(fmt) - - if not stream.access_token: - stream.access_token = secrets.token_hex() - stream.start() - return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(stream.access_token) - except Exception as err: - raise HomeAssistantError("Unable to get stream") from err + stream = Stream(hass, stream_source, options=options) + hass.data[DOMAIN][ATTR_STREAMS].append(stream) + return stream async def async_setup(hass, config): @@ -88,7 +76,7 @@ async def async_setup(hass, config): hass.data[DOMAIN] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {} - hass.data[DOMAIN][ATTR_STREAMS] = {} + hass.data[DOMAIN][ATTR_STREAMS] = [] # Setup HLS hls_endpoint = async_setup_hls(hass) @@ -100,60 +88,69 @@ async def async_setup(hass, config): @callback def shutdown(event): """Stop all stream workers.""" - for stream in hass.data[DOMAIN][ATTR_STREAMS].values(): + for stream in hass.data[DOMAIN][ATTR_STREAMS]: stream.keepalive = False stream.stop() _LOGGER.info("Stopped stream workers") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) - async def async_record(call): - """Call record stream service handler.""" - await async_handle_record_service(hass, call) - - hass.services.async_register( - DOMAIN, SERVICE_RECORD, async_record, schema=SERVICE_RECORD_SCHEMA - ) - return True class Stream: """Represents a single stream.""" - def __init__(self, hass, source, options=None, keepalive=False): + def __init__(self, hass, source, options=None): """Initialize a stream.""" self.hass = hass self.source = source self.options = options - self.keepalive = keepalive + self.keepalive = False self.access_token = None self._thread = None - self._thread_quit = None + self._thread_quit = threading.Event() self._outputs = {} + self._fast_restart_once = False if self.options is None: self.options = {} - @property + def endpoint_url(self, fmt): + """Start the stream and returns a url for the output format.""" + if fmt not in self._outputs: + raise ValueError(f"Stream is not configured for format '{fmt}'") + if not self.access_token: + self.access_token = secrets.token_hex() + return self.hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(self.access_token) + def outputs(self): """Return a copy of the stream outputs.""" # A copy is returned so the caller can iterate through the outputs # without concern about self._outputs being modified from another thread. return MappingProxyType(self._outputs.copy()) - def add_provider(self, fmt): + def add_provider(self, fmt, timeout=OUTPUT_IDLE_TIMEOUT): """Add provider output stream.""" if not self._outputs.get(fmt): - provider = PROVIDERS[fmt](self) + + @callback + def idle_callback(): + if (not self.keepalive or fmt == "recorder") and fmt in self._outputs: + self.remove_provider(self._outputs[fmt]) + self.check_idle() + + provider = PROVIDERS[fmt]( + self.hass, IdleTimer(self.hass, timeout, idle_callback) + ) self._outputs[fmt] = provider return self._outputs[fmt] def remove_provider(self, provider): """Remove provider output stream.""" if provider.name in self._outputs: + self._outputs[provider.name].cleanup() del self._outputs[provider.name] - self.check_idle() if not self._outputs: self.stop() @@ -165,24 +162,69 @@ class Stream: def start(self): """Start a stream.""" - # Keep import here so that we can import stream integration without installing reqs - # pylint: disable=import-outside-toplevel - from .worker import stream_worker - if self._thread is None or not self._thread.is_alive(): if self._thread is not None: # The thread must have crashed/exited. Join to clean up the # previous thread. self._thread.join(timeout=0) - self._thread_quit = threading.Event() + self._thread_quit.clear() self._thread = threading.Thread( name="stream_worker", - target=stream_worker, - args=(self.hass, self, self._thread_quit), + target=self._run_worker, ) self._thread.start() _LOGGER.info("Started stream: %s", self.source) + def update_source(self, new_source): + """Restart the stream with a new stream source.""" + _LOGGER.debug("Updating stream source %s", new_source) + self.source = new_source + self._fast_restart_once = True + self._thread_quit.set() + + def _run_worker(self): + """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 + + segment_buffer = SegmentBuffer(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() + if not self.keepalive or self._thread_quit.is_set(): + if self._fast_restart_once: + # The stream source is updated, restart without any delay. + self._fast_restart_once = False + 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. + # Reset the wait_timeout after the worker has been up for several minutes + if time.time() - start_time > STREAM_RESTART_RESET_TIME: + wait_timeout = 0 + wait_timeout += STREAM_RESTART_INCREMENT + _LOGGER.debug( + "Restarting stream worker in %d seconds: %s", + wait_timeout, + self.source, + ) + self._worker_finished() + + def _worker_finished(self): + """Schedule cleanup of all outputs.""" + + @callback + def remove_outputs(): + for provider in self.outputs().values(): + self.remove_provider(provider) + + self.hass.loop.call_soon_threadsafe(remove_outputs) + def stop(self): """Remove outputs and access token.""" self._outputs = {} @@ -199,40 +241,28 @@ class Stream: self._thread = None _LOGGER.info("Stopped stream: %s", self.source) + async def async_record(self, video_path, duration=30, lookback=5): + """Make a .mp4 recording from a provided stream.""" -async def async_handle_record_service(hass, call): - """Handle save video service calls.""" - stream_source = call.data[CONF_STREAM_SOURCE] - video_path = call.data[CONF_FILENAME] - duration = call.data[CONF_DURATION] - lookback = call.data[CONF_LOOKBACK] + # Check for file access + if not self.hass.config.is_allowed_path(video_path): + raise HomeAssistantError(f"Can't write {video_path}, no access to path!") - # Check for file access - if not hass.config.is_allowed_path(video_path): - raise HomeAssistantError(f"Can't write {video_path}, no access to path!") + # Add recorder + recorder = self.outputs().get("recorder") + if recorder: + raise HomeAssistantError( + f"Stream already recording to {recorder.video_path}!" + ) + recorder = self.add_provider("recorder", timeout=duration) + recorder.video_path = video_path - # Check for active stream - streams = hass.data[DOMAIN][ATTR_STREAMS] - stream = streams.get(stream_source) - if not stream: - stream = Stream(hass, stream_source) - streams[stream_source] = stream + self.start() - # Add recorder - recorder = stream.outputs.get("recorder") - if recorder: - raise HomeAssistantError(f"Stream already recording to {recorder.video_path}!") - - recorder = stream.add_provider("recorder") - recorder.video_path = video_path - recorder.timeout = duration - - stream.start() - - # Take advantage of lookback - hls = stream.outputs.get("hls") - if lookback > 0 and hls: - num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) - # Wait for latest segment, then add the lookback - await hls.recv() - recorder.prepend(list(hls.get_segment())[-num_segments:]) + # Take advantage of lookback + hls = self.outputs().get("hls") + if lookback > 0 and hls: + num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) + # Wait for latest segment, then add the lookback + await hls.recv() + recorder.prepend(list(hls.get_segment())[-num_segments:]) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index 181808e549e..a2557286cf1 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -1,21 +1,21 @@ """Constants for Stream component.""" DOMAIN = "stream" -CONF_STREAM_SOURCE = "stream_source" -CONF_LOOKBACK = "lookback" -CONF_DURATION = "duration" - ATTR_ENDPOINTS = "endpoints" ATTR_STREAMS = "streams" -ATTR_KEEPALIVE = "keepalive" - -SERVICE_RECORD = "record" OUTPUT_FORMATS = ["hls"] +SEGMENT_CONTAINER_FORMAT = "mp4" # format for segments +RECORDER_CONTAINER_FORMAT = "mp4" # format for recorder output +AUDIO_CODECS = {"aac", "mp3"} + FORMAT_CONTENT_TYPE = {"hls": "application/vnd.apple.mpegurl"} -MAX_SEGMENTS = 3 # Max number of segments to keep around +OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity + +NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist +MAX_SEGMENTS = 4 # Max number of segments to keep around MIN_SEGMENT_DURATION = 1.5 # Each segment is at least this many seconds PACKETS_TO_WAIT_FOR_AUDIO = 20 # Some streams have an audio stream with no audio diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 5158ba185b1..17d4516344a 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -8,11 +8,11 @@ from aiohttp import web import attr from homeassistant.components.http import HomeAssistantView -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.util.decorator import Registry -from .const import ATTR_STREAMS, DOMAIN, MAX_SEGMENTS +from .const import ATTR_STREAMS, DOMAIN PROVIDERS = Registry() @@ -34,20 +34,64 @@ class Segment: sequence: int = attr.ib() segment: io.BytesIO = attr.ib() duration: float = attr.ib() + # For detecting discontinuities across stream restarts + stream_id: int = attr.ib(default=0) + + +class IdleTimer: + """Invoke a callback after an inactivity timeout. + + The IdleTimer invokes the callback after some timeout has passed. The awake() method + resets the internal alarm, extending the inactivity time. + """ + + def __init__( + self, hass: HomeAssistant, timeout: int, idle_callback: Callable[[], None] + ): + """Initialize IdleTimer.""" + self._hass = hass + self._timeout = timeout + self._callback = idle_callback + self._unsub = None + self.idle = False + + def start(self): + """Start the idle timer if not already started.""" + self.idle = False + if self._unsub is None: + self._unsub = async_call_later(self._hass, self._timeout, self.fire) + + def awake(self): + """Keep the idle time alive by resetting the timeout.""" + self.idle = False + # Reset idle timeout + self.clear() + self._unsub = async_call_later(self._hass, self._timeout, self.fire) + + def clear(self): + """Clear and disable the timer if it has not already fired.""" + if self._unsub is not None: + self._unsub() + + def fire(self, _now=None): + """Invoke the idle timeout callback, called when the alarm fires.""" + self.idle = True + self._unsub = None + self._callback() class StreamOutput: """Represents a stream output.""" - def __init__(self, stream, timeout: int = 300) -> None: + def __init__( + self, hass: HomeAssistant, idle_timer: IdleTimer, deque_maxlen: int = None + ) -> None: """Initialize a stream output.""" - self.idle = False - self.timeout = timeout - self._stream = stream + self._hass = hass + self._idle_timer = idle_timer self._cursor = None self._event = asyncio.Event() - self._segments = deque(maxlen=MAX_SEGMENTS) - self._unsub = None + self._segments = deque(maxlen=deque_maxlen) @property def name(self) -> str: @@ -55,24 +99,9 @@ class StreamOutput: return None @property - def format(self) -> str: - """Return container format.""" - return None - - @property - def audio_codecs(self) -> str: - """Return desired audio codecs.""" - return None - - @property - def video_codecs(self) -> tuple: - """Return desired video codecs.""" - return None - - @property - def container_options(self) -> Callable[[int], dict]: - """Return Callable which takes a sequence number and returns container options.""" - return None + def idle(self) -> bool: + """Return True if the output is idle.""" + return self._idle_timer.idle @property def segments(self) -> List[int]: @@ -90,11 +119,7 @@ class StreamOutput: def get_segment(self, sequence: int = None) -> Any: """Retrieve a specific segment, or the whole list.""" - self.idle = False - # Reset idle timeout - if self._unsub is not None: - self._unsub() - self._unsub = async_call_later(self._stream.hass, self.timeout, self._timeout) + self._idle_timer.awake() if not sequence: return self._segments @@ -119,43 +144,22 @@ class StreamOutput: def put(self, segment: Segment) -> None: """Store output.""" - self._stream.hass.loop.call_soon_threadsafe(self._async_put, segment) + self._hass.loop.call_soon_threadsafe(self._async_put, segment) @callback def _async_put(self, segment: Segment) -> None: """Store output from event loop.""" # Start idle timeout when we start receiving data - if self._unsub is None: - self._unsub = async_call_later( - self._stream.hass, self.timeout, self._timeout - ) - - if segment is None: - self._event.set() - # Cleanup provider - if self._unsub is not None: - self._unsub() - self.cleanup() - return - + self._idle_timer.start() self._segments.append(segment) self._event.set() self._event.clear() - @callback - def _timeout(self, _now=None): - """Handle stream timeout.""" - self._unsub = None - if self._stream.keepalive: - self.idle = True - self._stream.check_idle() - else: - self.cleanup() - def cleanup(self): """Handle cleanup.""" - self._segments = deque(maxlen=MAX_SEGMENTS) - self._stream.remove_provider(self) + self._event.set() + self._idle_timer.clear() + self._segments = deque(maxlen=self._segments.maxlen) class StreamView(HomeAssistantView): @@ -174,11 +178,7 @@ class StreamView(HomeAssistantView): hass = request.app["hass"] stream = next( - ( - s - for s in hass.data[DOMAIN][ATTR_STREAMS].values() - if s.access_token == token - ), + (s for s in hass.data[DOMAIN][ATTR_STREAMS] if s.access_token == token), None, ) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 2b305442b80..b2600977971 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -1,13 +1,12 @@ """Provide functionality to stream HLS.""" import io -from typing import Callable from aiohttp import web from homeassistant.core import callback -from .const import FORMAT_CONTENT_TYPE -from .core import PROVIDERS, StreamOutput, StreamView +from .const import FORMAT_CONTENT_TYPE, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS +from .core import PROVIDERS, HomeAssistant, IdleTimer, StreamOutput, StreamView from .fmp4utils import get_codec_string, get_init, get_m4s @@ -77,21 +76,27 @@ class HlsPlaylistView(StreamView): @staticmethod def render_playlist(track): """Render playlist.""" - segments = track.segments + segments = list(track.get_segment())[-NUM_PLAYLIST_SEGMENTS:] if not segments: return [] - playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])] + playlist = [ + "#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0].sequence), + "#EXT-X-DISCONTINUITY-SEQUENCE:{}".format(segments[0].stream_id), + ] - for sequence in segments: - segment = track.get_segment(sequence) + last_stream_id = segments[0].stream_id + for segment in segments: + if last_stream_id != segment.stream_id: + playlist.append("#EXT-X-DISCONTINUITY") playlist.extend( [ "#EXTINF:{:.04f},".format(float(segment.duration)), f"./segment/{segment.sequence}.m4s", ] ) + last_stream_id = segment.stream_id return playlist @@ -153,32 +158,11 @@ class HlsSegmentView(StreamView): class HlsStreamOutput(StreamOutput): """Represents HLS Output formats.""" + def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + """Initialize recorder output.""" + super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) + @property def name(self) -> str: """Return provider name.""" return "hls" - - @property - def format(self) -> str: - """Return container format.""" - return "mp4" - - @property - def audio_codecs(self) -> str: - """Return desired audio codecs.""" - return {"aac", "mp3"} - - @property - def video_codecs(self) -> tuple: - """Return desired video codecs.""" - return {"hevc", "h264"} - - @property - def container_options(self) -> Callable[[int], dict]: - """Return Callable which takes a sequence number and returns container options.""" - return lambda sequence: { - # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 - "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont", - "avoid_negative_ts": "make_non_negative", - "fragment_index": str(sequence), - } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 3d194bdf0d4..19b9e7b2e8a 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["av==8.0.2"], + "requirements": ["av==8.0.3"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin"], "quality_scale": "internal" diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index cf923de85c2..0344e220647 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -2,13 +2,14 @@ import logging import os import threading -from typing import List +from typing import Deque, List import av -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback -from .core import PROVIDERS, Segment, StreamOutput +from .const import RECORDER_CONTAINER_FORMAT, SEGMENT_CONTAINER_FORMAT +from .core import PROVIDERS, IdleTimer, Segment, StreamOutput _LOGGER = logging.getLogger(__name__) @@ -18,51 +19,78 @@ def async_setup_recorder(hass): """Only here so Provider Registry works.""" -def recorder_save_worker(file_out: str, segments: List[Segment], container_format: str): +def recorder_save_worker(file_out: str, segments: Deque[Segment]): """Handle saving stream.""" if not os.path.exists(os.path.dirname(file_out)): os.makedirs(os.path.dirname(file_out), exist_ok=True) - first_pts = {"video": None, "audio": None} - output = av.open(file_out, "w", format=container_format) + pts_adjuster = {"video": None, "audio": None} + output = None output_v = None output_a = None - # Get first_pts values from first segment - if len(segments) > 0: - segment = segments[0] - source = av.open(segment.segment, "r", format=container_format) - source_v = source.streams.video[0] - first_pts["video"] = source_v.start_time - if len(source.streams.audio) > 0: - source_a = source.streams.audio[0] - first_pts["audio"] = int( - source_v.start_time * source_v.time_base / source_a.time_base - ) - source.close() + last_stream_id = None + # The running duration of processed segments. Note that this is in av.time_base + # units which seem to be defined inversely to how stream time_bases are defined + running_duration = 0 + last_sequence = float("-inf") for segment in segments: + # Because the stream_worker is in a different thread from the record service, + # the lookback segments may still have some overlap with the recorder segments + if segment.sequence <= last_sequence: + continue + last_sequence = segment.sequence + # Open segment - source = av.open(segment.segment, "r", format=container_format) + source = av.open(segment.segment, "r", format=SEGMENT_CONTAINER_FORMAT) source_v = source.streams.video[0] - # Add output streams + source_a = source.streams.audio[0] if len(source.streams.audio) > 0 else None + + # Create output on first segment + if not output: + output = av.open( + file_out, + "w", + format=RECORDER_CONTAINER_FORMAT, + container_options={ + "video_track_timescale": str(int(1 / source_v.time_base)) + }, + ) + + # Add output streams if necessary if not output_v: output_v = output.add_stream(template=source_v) context = output_v.codec_context context.flags |= "GLOBAL_HEADER" - if not output_a and len(source.streams.audio) > 0: - source_a = source.streams.audio[0] + if source_a and not output_a: output_a = output.add_stream(template=source_a) + # Recalculate pts adjustments on first segment and on any discontinuity + # We are assuming time base is the same across all discontinuities + if last_stream_id != segment.stream_id: + last_stream_id = segment.stream_id + pts_adjuster["video"] = int( + (running_duration - source.start_time) + / (av.time_base * source_v.time_base) + ) + if source_a: + pts_adjuster["audio"] = int( + (running_duration - source.start_time) + / (av.time_base * source_a.time_base) + ) + # Remux video for packet in source.demux(): if packet.dts is None: continue - packet.pts -= first_pts[packet.stream.type] - packet.dts -= first_pts[packet.stream.type] + packet.pts += pts_adjuster[packet.stream.type] + packet.dts += pts_adjuster[packet.stream.type] packet.stream = output_v if packet.stream.type == "video" else output_a output.mux(packet) + running_duration += source.duration - source.start_time + source.close() output.close() @@ -72,43 +100,19 @@ def recorder_save_worker(file_out: str, segments: List[Segment], container_forma class RecorderOutput(StreamOutput): """Represents HLS Output formats.""" - def __init__(self, stream, timeout: int = 30) -> None: + def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: """Initialize recorder output.""" - super().__init__(stream, timeout) + super().__init__(hass, idle_timer) self.video_path = None - self._segments = [] @property def name(self) -> str: """Return provider name.""" return "recorder" - @property - def format(self) -> str: - """Return container format.""" - return "mp4" - - @property - def audio_codecs(self) -> str: - """Return desired audio codec.""" - return {"aac", "mp3"} - - @property - def video_codecs(self) -> tuple: - """Return desired video codecs.""" - return {"hevc", "h264"} - def prepend(self, segments: List[Segment]) -> None: """Prepend segments to existing list.""" - own_segments = self.segments - segments = [s for s in segments if s.sequence not in own_segments] - self._segments = segments + self._segments - - @callback - def _timeout(self, _now=None): - """Handle recorder timeout.""" - self._unsub = None - self.cleanup() + self._segments.extendleft(reversed(segments)) def cleanup(self): """Write recording and clean up.""" @@ -116,9 +120,8 @@ class RecorderOutput(StreamOutput): thread = threading.Thread( name="recorder_save_worker", target=recorder_save_worker, - args=(self.video_path, self._segments, self.format), + args=(self.video_path, self._segments), ) thread.start() - self._segments = [] - self._stream.remove_provider(self) + super().cleanup() diff --git a/homeassistant/components/stream/services.yaml b/homeassistant/components/stream/services.yaml deleted file mode 100644 index a8652335bf1..00000000000 --- a/homeassistant/components/stream/services.yaml +++ /dev/null @@ -1,15 +0,0 @@ -record: - description: Make a .mp4 recording from a provided stream. - fields: - stream_source: - description: The input source for the stream. - example: "rtsp://my.stream.feed:554" - filename: - description: The file name string. - example: "/tmp/my_stream.mp4" - duration: - description: "Target recording length (in seconds). Default: 30" - example: 30 - lookback: - description: "Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream for stream_source. Default: 0" - example: 5 diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index cccbfd1b48b..d5760877c43 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -2,17 +2,16 @@ from collections import deque import io import logging -import time import av from .const import ( + AUDIO_CODECS, MAX_MISSING_DTS, MAX_TIMESTAMP_GAP, MIN_SEGMENT_DURATION, PACKETS_TO_WAIT_FOR_AUDIO, - STREAM_RESTART_INCREMENT, - STREAM_RESTART_RESET_TIME, + SEGMENT_CONTAINER_FORMAT, STREAM_TIMEOUT, ) from .core import Segment, StreamBuffer @@ -20,19 +19,20 @@ from .core import Segment, StreamBuffer _LOGGER = logging.getLogger(__name__) -def create_stream_buffer(stream_output, video_stream, audio_stream, sequence): +def create_stream_buffer(video_stream, audio_stream, sequence): """Create a new StreamBuffer.""" segment = io.BytesIO() - container_options = ( - stream_output.container_options(sequence) - if stream_output.container_options - else {} - ) + container_options = { + # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 + "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont", + "avoid_negative_ts": "disabled", + "fragment_index": str(sequence), + } output = av.open( segment, mode="w", - format=stream_output.format, + format=SEGMENT_CONTAINER_FORMAT, container_options={ "video_track_timescale": str(int(1 / video_stream.time_base)), **container_options, @@ -41,46 +41,93 @@ def create_stream_buffer(stream_output, video_stream, audio_stream, sequence): vstream = output.add_stream(template=video_stream) # Check if audio is requested astream = None - if audio_stream and audio_stream.name in stream_output.audio_codecs: + if audio_stream and audio_stream.name in AUDIO_CODECS: astream = output.add_stream(template=audio_stream) return StreamBuffer(segment, output, vstream, astream) -def stream_worker(hass, stream, quit_event): - """Handle consuming streams and restart keepalive streams.""" +class SegmentBuffer: + """Buffer for writing a sequence of packets to the output as a segment.""" - wait_timeout = 0 - while not quit_event.wait(timeout=wait_timeout): - start_time = time.time() - try: - _stream_worker_internal(hass, stream, quit_event) - except av.error.FFmpegError: # pylint: disable=c-extension-no-member - _LOGGER.exception("Stream connection failed: %s", stream.source) - if not stream.keepalive or quit_event.is_set(): - 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. - # Reset the wait_timeout after the worker has been up for several minutes - if time.time() - start_time > STREAM_RESTART_RESET_TIME: - wait_timeout = 0 - wait_timeout += STREAM_RESTART_INCREMENT - _LOGGER.debug( - "Restarting stream worker in %d seconds: %s", - wait_timeout, - stream.source, + def __init__(self, outputs_callback) -> None: + """Initialize SegmentBuffer.""" + self._stream_id = 0 + self._video_stream = None + self._audio_stream = None + self._outputs_callback = outputs_callback + # Each element is a StreamOutput + self._outputs = [] + self._sequence = 0 + self._segment_start_pts = None + self._stream_buffer = None + + def set_streams(self, video_stream, audio_stream): + """Initialize output buffer with streams from container.""" + self._video_stream = video_stream + self._audio_stream = audio_stream + + def reset(self, video_pts): + """Initialize a new stream segment.""" + # Keep track of the number of segments we've processed + self._sequence += 1 + self._segment_start_pts = video_pts + + # Fetch the latest StreamOutputs, which may have changed since the + # worker started. + self._outputs = self._outputs_callback().values() + self._stream_buffer = create_stream_buffer( + self._video_stream, self._audio_stream, self._sequence ) + def mux_packet(self, packet): + """Mux a packet to the appropriate StreamBuffers.""" -def _stream_worker_internal(hass, stream, quit_event): + # Check for end of segment + if packet.stream == self._video_stream and packet.is_keyframe: + duration = (packet.pts - self._segment_start_pts) * packet.time_base + if duration >= MIN_SEGMENT_DURATION: + # Save segment to outputs + self.flush(duration) + + # Reinitialize + self.reset(packet.pts) + + # Mux the packet + if packet.stream == self._video_stream: + packet.stream = self._stream_buffer.vstream + self._stream_buffer.output.mux(packet) + elif packet.stream == self._audio_stream: + packet.stream = self._stream_buffer.astream + self._stream_buffer.output.mux(packet) + + def flush(self, duration): + """Create a segment from the buffered packets and write to output.""" + self._stream_buffer.output.close() + segment = Segment( + self._sequence, self._stream_buffer.segment, duration, self._stream_id + ) + for stream_output in self._outputs: + stream_output.put(segment) + + def discontinuity(self): + """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 + + def close(self): + """Close stream buffer.""" + self._stream_buffer.output.close() + + +def stream_worker(source, options, segment_buffer, quit_event): """Handle consuming streams.""" try: - container = av.open( - stream.source, options=stream.options, timeout=STREAM_TIMEOUT - ) + container = av.open(source, options=options, timeout=STREAM_TIMEOUT) except av.AVError: - _LOGGER.error("Error opening stream %s", stream.source) + _LOGGER.error("Error opening stream %s", source) return try: video_stream = container.streams.video[0] @@ -106,10 +153,6 @@ def _stream_worker_internal(hass, stream, quit_event): last_dts = {video_stream: float("-inf"), audio_stream: float("-inf")} # Keep track of consecutive packets without a dts to detect end of stream. missing_dts = 0 - # Holds the buffers for each stream provider - outputs = None - # Keep track of the number of segments we've processed - sequence = 0 # The video pts at the beginning of the segment segment_start_pts = None # Because of problems 1 and 2 below, we need to store the first few packets and replay them @@ -183,54 +226,15 @@ def _stream_worker_internal(hass, stream, quit_event): _LOGGER.error( "Error demuxing stream while finding first packet: %s", str(ex) ) - finalize_stream() return False return True - def initialize_segment(video_pts): - """Reset some variables and initialize outputs for each segment.""" - nonlocal outputs, sequence, segment_start_pts - # Clear outputs and increment sequence - outputs = {} - sequence += 1 - segment_start_pts = video_pts - for stream_output in stream.outputs.values(): - if video_stream.name not in stream_output.video_codecs: - continue - buffer = create_stream_buffer( - stream_output, video_stream, audio_stream, sequence - ) - outputs[stream_output.name] = ( - buffer, - {video_stream: buffer.vstream, audio_stream: buffer.astream}, - ) - - def mux_video_packet(packet): - # mux packets to each buffer - for buffer, output_streams in outputs.values(): - # Assign the packet to the new stream & mux - packet.stream = output_streams[video_stream] - buffer.output.mux(packet) - - def mux_audio_packet(packet): - # almost the same as muxing video but add extra check - for buffer, output_streams in outputs.values(): - # Assign the packet to the new stream & mux - if output_streams.get(audio_stream): - packet.stream = output_streams[audio_stream] - buffer.output.mux(packet) - - def finalize_stream(): - if not stream.keepalive: - # End of stream, clear listeners and stop thread - for fmt in stream.outputs: - stream.outputs[fmt].put(None) - if not peek_first_pts(): container.close() return - initialize_segment(segment_start_pts) + segment_buffer.set_streams(video_stream, audio_stream) + segment_buffer.reset(segment_start_pts) while not quit_event.is_set(): try: @@ -249,7 +253,6 @@ def _stream_worker_internal(hass, stream, quit_event): missing_dts = 0 except (av.AVError, StopIteration) as ex: _LOGGER.error("Error demuxing stream: %s", str(ex)) - finalize_stream() break # Discard packet if dts is not monotonic @@ -263,38 +266,16 @@ def _stream_worker_internal(hass, stream, quit_event): last_dts[packet.stream], packet.dts, ) - finalize_stream() break continue - # Check for end of segment - if packet.stream == video_stream and packet.is_keyframe: - segment_duration = (packet.pts - segment_start_pts) * packet.time_base - if segment_duration >= MIN_SEGMENT_DURATION: - # Save segment to outputs - for fmt, (buffer, _) in outputs.items(): - buffer.output.close() - if stream.outputs.get(fmt): - stream.outputs[fmt].put( - Segment( - sequence, - buffer.segment, - segment_duration, - ), - ) - - # Reinitialize - initialize_segment(packet.pts) - # Update last_dts processed last_dts[packet.stream] = packet.dts - # mux packets - if packet.stream == video_stream: - mux_video_packet(packet) # mutates packet timestamps - else: - mux_audio_packet(packet) # mutates packet timestamps + + # Mux packets, and possibly write a segment to the output stream. + # This mutates packet timestamps and stream + segment_buffer.mux_packet(packet) # Close stream - for buffer, _ in outputs.values(): - buffer.output.close() + segment_buffer.close() container.close() diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 43ef01a497e..0ad621f0707 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -62,7 +62,7 @@ async def async_setup(hass: HomeAssistantType, config): return setup_tasks = [ - async_setup_platform(p_type, p_config) + asyncio.create_task(async_setup_platform(p_type, p_config)) for p_type, p_config in config_per_platform(config, DOMAIN) ] diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py new file mode 100644 index 00000000000..63bc644b50a --- /dev/null +++ b/homeassistant/components/subaru/__init__.py @@ -0,0 +1,173 @@ +"""The Subaru integration.""" +import asyncio +from datetime import timedelta +import logging +import time + +from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_COUNTRY, + CONF_UPDATE_ENABLED, + COORDINATOR_NAME, + DOMAIN, + ENTRY_CONTROLLER, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + FETCH_INTERVAL, + SUPPORTED_PLATFORMS, + UPDATE_INTERVAL, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_LAST_UPDATE, + VEHICLE_NAME, + VEHICLE_VIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, base_config): + """Do nothing since this integration does not support configuration.yml setup.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass, entry): + """Set up Subaru from a config entry.""" + config = entry.data + websession = aiohttp_client.async_get_clientsession(hass) + try: + controller = SubaruAPI( + websession, + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_DEVICE_ID], + config[CONF_PIN], + None, + config[CONF_COUNTRY], + update_interval=UPDATE_INTERVAL, + fetch_interval=FETCH_INTERVAL, + ) + _LOGGER.debug("Using subarulink %s", controller.version) + await controller.connect() + except InvalidCredentials: + _LOGGER.error("Invalid account") + return False + except SubaruException as err: + raise ConfigEntryNotReady(err.message) from err + + vehicle_info = {} + for vin in controller.get_vehicles(): + vehicle_info[vin] = get_vehicle_info(controller, vin) + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await refresh_subaru_data(entry, vehicle_info, controller) + except SubaruException as err: + raise UpdateFailed(err.message) from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=COORDINATOR_NAME, + update_method=async_update_data, + update_interval=timedelta(seconds=FETCH_INTERVAL), + ) + + await coordinator.async_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + ENTRY_CONTROLLER: controller, + ENTRY_COORDINATOR: coordinator, + ENTRY_VEHICLES: vehicle_info, + } + + for component in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in SUPPORTED_PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def refresh_subaru_data(config_entry, vehicle_info, controller): + """ + Refresh local data with data fetched via Subaru API. + + Subaru API calls assume a server side vehicle context + Data fetch/update must be done for each vehicle + """ + data = {} + + for vehicle in vehicle_info.values(): + vin = vehicle[VEHICLE_VIN] + + # Active subscription required + if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: + continue + + # Optionally send an "update" remote command to vehicle (throttled with update_interval) + if config_entry.options.get(CONF_UPDATE_ENABLED, False): + await update_subaru(vehicle, controller) + + # Fetch data from Subaru servers + 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: + data[vin] = received_data + + return data + + +async def update_subaru(vehicle, controller): + """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" + cur_time = time.time() + last_update = vehicle[VEHICLE_LAST_UPDATE] + + if cur_time - last_update > controller.get_update_interval(): + await controller.update(vehicle[VEHICLE_VIN], force=True) + vehicle[VEHICLE_LAST_UPDATE] = cur_time + + +def get_vehicle_info(controller, vin): + """Obtain vehicle identifiers and capabilities.""" + info = { + VEHICLE_VIN: vin, + VEHICLE_NAME: controller.vin_to_name(vin), + VEHICLE_HAS_EV: controller.get_ev_status(vin), + VEHICLE_API_GEN: controller.get_api_gen(vin), + VEHICLE_HAS_REMOTE_START: controller.get_res_status(vin), + VEHICLE_HAS_REMOTE_SERVICE: controller.get_remote_status(vin), + VEHICLE_HAS_SAFETY_SERVICE: controller.get_safety_status(vin), + VEHICLE_LAST_UPDATE: 0, + } + return info diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py new file mode 100644 index 00000000000..4c5c476a402 --- /dev/null +++ b/homeassistant/components/subaru/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Subaru integration.""" +from datetime import datetime +import logging + +from subarulink import ( + Controller as SubaruAPI, + InvalidCredentials, + InvalidPIN, + SubaruException, +) +from subarulink.const import COUNTRY_CAN, COUNTRY_USA +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +# pylint: disable=unused-import +from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN + +_LOGGER = logging.getLogger(__name__) +PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) + + +class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Subaru.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + config_data = {CONF_PIN: None} + controller = None + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + error = None + + if user_input: + if user_input[CONF_USERNAME] in [ + entry.data[CONF_USERNAME] for entry in self._async_current_entries() + ]: + return self.async_abort(reason="already_configured") + + try: + await self.validate_login_creds(user_input) + except InvalidCredentials: + error = {"base": "invalid_auth"} + except SubaruException as ex: + _LOGGER.error("Unable to communicate with Subaru API: %s", ex.message) + return self.async_abort(reason="cannot_connect") + else: + if self.controller.is_pin_required(): + return await self.async_step_pin() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=self.config_data + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, + default=user_input.get(CONF_USERNAME) if user_input else "", + ): str, + vol.Required( + CONF_PASSWORD, + default=user_input.get(CONF_PASSWORD) if user_input else "", + ): str, + vol.Required( + CONF_COUNTRY, + default=user_input.get(CONF_COUNTRY) + if user_input + else COUNTRY_USA, + ): vol.In([COUNTRY_CAN, COUNTRY_USA]), + } + ), + errors=error, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def validate_login_creds(self, data): + """Validate the user input allows us to connect. + + data: contains values provided by the user. + """ + websession = aiohttp_client.async_get_clientsession(self.hass) + now = datetime.now() + if not data.get(CONF_DEVICE_ID): + data[CONF_DEVICE_ID] = int(now.timestamp()) + date = now.strftime("%Y-%m-%d") + device_name = "Home Assistant: Added " + date + + self.controller = SubaruAPI( + websession, + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + device_id=data[CONF_DEVICE_ID], + pin=None, + device_name=device_name, + country=data[CONF_COUNTRY], + ) + _LOGGER.debug("Using subarulink %s", self.controller.version) + _LOGGER.debug( + "Setting up first time connection to Subuaru API. This may take up to 20 seconds." + ) + if await self.controller.connect(): + _LOGGER.debug("Successfully authenticated and authorized with Subaru API") + self.config_data.update(data) + + async def async_step_pin(self, user_input=None): + """Handle second part of config flow, if required.""" + error = None + if user_input: + if self.controller.update_saved_pin(user_input[CONF_PIN]): + try: + vol.Match(r"[0-9]{4}")(user_input[CONF_PIN]) + await self.controller.test_pin() + except vol.Invalid: + error = {"base": "bad_pin_format"} + except InvalidPIN: + error = {"base": "incorrect_pin"} + else: + _LOGGER.debug("PIN successfully tested") + self.config_data.update(user_input) + return self.async_create_entry( + title=self.config_data[CONF_USERNAME], data=self.config_data + ) + return self.async_show_form(step_id="pin", data_schema=PIN_SCHEMA, errors=error) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Subaru.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_UPDATE_ENABLED, + default=self.config_entry.options.get(CONF_UPDATE_ENABLED, False), + ): cv.boolean, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py new file mode 100644 index 00000000000..7349f9c32d6 --- /dev/null +++ b/homeassistant/components/subaru/const.py @@ -0,0 +1,47 @@ +"""Constants for the Subaru integration.""" + +DOMAIN = "subaru" +FETCH_INTERVAL = 300 +UPDATE_INTERVAL = 7200 +CONF_UPDATE_ENABLED = "update_enabled" +CONF_COUNTRY = "country" + +# entry fields +ENTRY_CONTROLLER = "controller" +ENTRY_COORDINATOR = "coordinator" +ENTRY_VEHICLES = "vehicles" + +# update coordinator name +COORDINATOR_NAME = "subaru_data" + +# info fields +VEHICLE_VIN = "vin" +VEHICLE_NAME = "display_name" +VEHICLE_HAS_EV = "is_ev" +VEHICLE_API_GEN = "api_gen" +VEHICLE_HAS_REMOTE_START = "has_res" +VEHICLE_HAS_REMOTE_SERVICE = "has_remote" +VEHICLE_HAS_SAFETY_SERVICE = "has_safety" +VEHICLE_LAST_UPDATE = "last_update" +VEHICLE_STATUS = "status" + + +API_GEN_1 = "g1" +API_GEN_2 = "g2" +MANUFACTURER = "Subaru Corp." + +SUPPORTED_PLATFORMS = [ + "sensor", +] + +ICONS = { + "Avg Fuel Consumption": "mdi:leaf", + "EV Time to Full Charge": "mdi:car-electric", + "EV Range": "mdi:ev-station", + "Odometer": "mdi:road-variant", + "Range": "mdi:gas-station", + "Tire Pressure FL": "mdi:gauge", + "Tire Pressure FR": "mdi:gauge", + "Tire Pressure RL": "mdi:gauge", + "Tire Pressure RR": "mdi:gauge", +} diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 00000000000..4fdeca4e484 --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,39 @@ +"""Base class for all Subaru Entities.""" +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, ICONS, MANUFACTURER, VEHICLE_NAME, VEHICLE_VIN + + +class SubaruEntity(CoordinatorEntity): + """Representation of a Subaru Entity.""" + + def __init__(self, vehicle_info, coordinator): + """Initialize the Subaru Entity.""" + super().__init__(coordinator) + self.car_name = vehicle_info[VEHICLE_NAME] + self.vin = vehicle_info[VEHICLE_VIN] + self.entity_type = "entity" + + @property + def name(self): + """Return name.""" + return f"{self.car_name} {self.entity_type}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.vin}_{self.entity_type}" + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICONS.get(self.entity_type) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.vin)}, + "name": self.car_name, + "manufacturer": MANUFACTURER, + } diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json new file mode 100644 index 00000000000..7a918c59f74 --- /dev/null +++ b/homeassistant/components/subaru/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "subaru", + "name": "Subaru", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/subaru", + "requirements": ["subarulink==0.3.12"], + "codeowners": ["@G-Two"] +} diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py new file mode 100644 index 00000000000..594d18028e6 --- /dev/null +++ b/homeassistant/components/subaru/sensor.py @@ -0,0 +1,265 @@ +"""Support for Subaru sensors.""" +import subarulink.const as sc + +from homeassistant.components.sensor import DEVICE_CLASSES +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + LENGTH_KILOMETERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, + TIME_MINUTES, + VOLT, + VOLUME_GALLONS, + VOLUME_LITERS, +) +from homeassistant.util.distance import convert as dist_convert +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM, + LENGTH_UNITS, + PRESSURE_UNITS, + TEMPERATURE_UNITS, +) +from homeassistant.util.volume import convert as vol_convert + +from .const import ( + API_GEN_2, + DOMAIN, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_STATUS, +) +from .entity import SubaruEntity + +L_PER_GAL = vol_convert(1, VOLUME_GALLONS, VOLUME_LITERS) +KM_PER_MI = dist_convert(1, LENGTH_MILES, LENGTH_KILOMETERS) + +# Fuel Economy Constants +FUEL_CONSUMPTION_L_PER_100KM = "L/100km" +FUEL_CONSUMPTION_MPG = "mi/gal" +FUEL_CONSUMPTION_UNITS = [FUEL_CONSUMPTION_L_PER_100KM, FUEL_CONSUMPTION_MPG] + +SENSOR_TYPE = "type" +SENSOR_CLASS = "class" +SENSOR_FIELD = "field" +SENSOR_UNITS = "units" + +# Sensor data available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +SAFETY_SENSORS = [ + { + SENSOR_TYPE: "Odometer", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.ODOMETER, + SENSOR_UNITS: LENGTH_KILOMETERS, + }, +] + +# Sensor data available to "Subaru Safety Plus" subscribers with Gen2 vehicles +API_GEN_2_SENSORS = [ + { + SENSOR_TYPE: "Avg Fuel Consumption", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.AVG_FUEL_CONSUMPTION, + SENSOR_UNITS: FUEL_CONSUMPTION_L_PER_100KM, + }, + { + SENSOR_TYPE: "Range", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.DIST_TO_EMPTY, + SENSOR_UNITS: LENGTH_KILOMETERS, + }, + { + SENSOR_TYPE: "Tire Pressure FL", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_FL, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure FR", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_FR, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure RL", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_RL, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure RR", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_RR, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "External Temp", + SENSOR_CLASS: DEVICE_CLASS_TEMPERATURE, + SENSOR_FIELD: sc.EXTERNAL_TEMP, + SENSOR_UNITS: TEMP_CELSIUS, + }, + { + SENSOR_TYPE: "12V Battery Voltage", + SENSOR_CLASS: DEVICE_CLASS_VOLTAGE, + SENSOR_FIELD: sc.BATTERY_VOLTAGE, + SENSOR_UNITS: VOLT, + }, +] + +# Sensor data available to "Subaru Safety Plus" subscribers with PHEV vehicles +EV_SENSORS = [ + { + SENSOR_TYPE: "EV Range", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.EV_DISTANCE_TO_EMPTY, + SENSOR_UNITS: LENGTH_MILES, + }, + { + SENSOR_TYPE: "EV Battery Level", + SENSOR_CLASS: DEVICE_CLASS_BATTERY, + SENSOR_FIELD: sc.EV_STATE_OF_CHARGE_PERCENT, + SENSOR_UNITS: PERCENTAGE, + }, + { + SENSOR_TYPE: "EV Time to Full Charge", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.EV_TIME_TO_FULLY_CHARGED, + SENSOR_UNITS: TIME_MINUTES, + }, +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Subaru sensors by config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + vehicle_info = hass.data[DOMAIN][config_entry.entry_id][ENTRY_VEHICLES] + entities = [] + for vin in vehicle_info.keys(): + entities.extend(create_vehicle_sensors(vehicle_info[vin], coordinator)) + async_add_entities(entities, True) + + +def create_vehicle_sensors(vehicle_info, coordinator): + """Instantiate all available sensors for the vehicle.""" + sensors_to_add = [] + if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: + sensors_to_add.extend(SAFETY_SENSORS) + + if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: + sensors_to_add.extend(API_GEN_2_SENSORS) + + if vehicle_info[VEHICLE_HAS_EV]: + sensors_to_add.extend(EV_SENSORS) + + return [ + SubaruSensor( + vehicle_info, + coordinator, + s[SENSOR_TYPE], + s[SENSOR_CLASS], + s[SENSOR_FIELD], + s[SENSOR_UNITS], + ) + for s in sensors_to_add + ] + + +class SubaruSensor(SubaruEntity): + """Class for Subaru sensors.""" + + def __init__( + self, vehicle_info, coordinator, entity_type, sensor_class, data_field, api_unit + ): + """Initialize the sensor.""" + super().__init__(vehicle_info, coordinator) + self.hass_type = "sensor" + self.current_value = None + self.entity_type = entity_type + self.sensor_class = sensor_class + self.data_field = data_field + self.api_unit = api_unit + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + if self.sensor_class in DEVICE_CLASSES: + return self.sensor_class + return super().device_class + + @property + def state(self): + """Return the state of the sensor.""" + self.current_value = self.get_current_value() + + if self.current_value is None: + return None + + if self.api_unit in TEMPERATURE_UNITS: + return round( + self.hass.config.units.temperature(self.current_value, self.api_unit), 1 + ) + + if self.api_unit in LENGTH_UNITS: + return round( + self.hass.config.units.length(self.current_value, self.api_unit), 1 + ) + + if self.api_unit in PRESSURE_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return round( + self.hass.config.units.pressure(self.current_value, self.api_unit), + 1, + ) + + if self.api_unit in FUEL_CONSUMPTION_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return round((100.0 * L_PER_GAL) / (KM_PER_MI * self.current_value), 1) + + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + if self.api_unit in TEMPERATURE_UNITS: + return self.hass.config.units.temperature_unit + + if self.api_unit in LENGTH_UNITS: + return self.hass.config.units.length_unit + + if self.api_unit in PRESSURE_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return self.hass.config.units.pressure_unit + return PRESSURE_HPA + + if self.api_unit in FUEL_CONSUMPTION_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return FUEL_CONSUMPTION_MPG + return FUEL_CONSUMPTION_L_PER_100KM + + return self.api_unit + + @property + def available(self): + """Return if entity is available.""" + last_update_success = super().available + if last_update_success and self.vin not in self.coordinator.data: + return False + return last_update_success + + def get_current_value(self): + """Get raw value from the coordinator.""" + value = self.coordinator.data[self.vin][VEHICLE_STATUS].get(self.data_field) + if value in sc.BAD_SENSOR_VALUES: + value = None + if isinstance(value, str): + if "." in value: + value = float(value) + else: + value = int(value) + return value diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json new file mode 100644 index 00000000000..064245e0732 --- /dev/null +++ b/homeassistant/components/subaru/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "title": "Subaru Starlink Configuration", + "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "country": "Select country" + } + }, + "pin": { + "title": "Subaru Starlink Configuration", + "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", + "data": { + "pin": "PIN" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "incorrect_pin": "Incorrect PIN", + "bad_pin_format": "PIN should be 4 digits", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + + "options": { + "step": { + "init": { + "title": "Subaru Starlink Options", + "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", + "data": { + "update_enabled": "Enable vehicle polling" + } + } + } + } +} diff --git a/homeassistant/components/subaru/translations/ca.json b/homeassistant/components/subaru/translations/ca.json new file mode 100644 index 00000000000..51cd44b4ce2 --- /dev/null +++ b/homeassistant/components/subaru/translations/ca.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "error": { + "bad_pin_format": "El PIN ha de tenir 4 d\u00edgits", + "cannot_connect": "Ha fallat la connexi\u00f3", + "incorrect_pin": "PIN incorrecte", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Introdueix el teu PIN de MySubaru\nNOTA: tots els vehicles associats a un compte han de tenir el mateix PIN", + "title": "Configuraci\u00f3 de Subaru Starlink" + }, + "user": { + "data": { + "country": "Selecciona un pa\u00eds", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les teves credencials de MySubaru\nNOTA: la primera configuraci\u00f3 pot tardar fins a 30 segons", + "title": "Configuraci\u00f3 de Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Activa el sondeig de vehicle" + }, + "description": "Quan estigui activat, el sondeig de vehicle enviar\u00e0 una ordre al vehicle cada 2 hores per tal d'obtenir noves dades. Sense el sondeig de vehicle, les noves dades nom\u00e9s es rebr\u00e0n quan el vehicle envia autom\u00e0ticament les dades (normalment despr\u00e9s de l'aturada del motor).", + "title": "Opcions de Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/cs.json b/homeassistant/components/subaru/translations/cs.json new file mode 100644 index 00000000000..ee3bf7347ca --- /dev/null +++ b/homeassistant/components/subaru/translations/cs.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "error": { + "bad_pin_format": "PIN by m\u011bl m\u00edt 4 \u010d\u00edslice", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "incorrect_pin": "Nespr\u00e1vn\u00fd PIN", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "pin": { + "data": { + "pin": "PIN k\u00f3d" + } + }, + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/de.json b/homeassistant/components/subaru/translations/de.json new file mode 100644 index 00000000000..1c162d61e99 --- /dev/null +++ b/homeassistant/components/subaru/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "error": { + "bad_pin_format": "Die PIN sollte 4-stellig sein", + "cannot_connect": "Verbindung fehlgeschlagen", + "incorrect_pin": "Falsche PIN", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + } + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/en.json b/homeassistant/components/subaru/translations/en.json new file mode 100644 index 00000000000..ea15ff00552 --- /dev/null +++ b/homeassistant/components/subaru/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "cannot_connect": "Failed to connect" + }, + "error": { + "bad_pin_format": "PIN should be 4 digits", + "cannot_connect": "Failed to connect", + "incorrect_pin": "Incorrect PIN", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", + "title": "Subaru Starlink Configuration" + }, + "user": { + "data": { + "country": "Select country", + "password": "Password", + "username": "Username" + }, + "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", + "title": "Subaru Starlink Configuration" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Enable vehicle polling" + }, + "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", + "title": "Subaru Starlink Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/es.json b/homeassistant/components/subaru/translations/es.json new file mode 100644 index 00000000000..deccc23c75d --- /dev/null +++ b/homeassistant/components/subaru/translations/es.json @@ -0,0 +1,37 @@ +{ + "config": { + "error": { + "bad_pin_format": "El PIN debe tener 4 d\u00edgitos", + "incorrect_pin": "PIN incorrecto" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Por favor, introduzca su PIN de MySubaru\nNOTA: Todos los veh\u00edculos de la cuenta deben tener el mismo PIN", + "title": "Configuraci\u00f3n de Subaru Starlink" + }, + "user": { + "data": { + "country": "Seleccionar pa\u00eds", + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Por favor, introduzca sus credenciales de MySubaru\nNOTA: La configuraci\u00f3n inicial puede tardar hasta 30 segundos", + "title": "Configuraci\u00f3n de Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Habilitar el sondeo de veh\u00edculos" + }, + "description": "Cuando est\u00e1 habilitado, el sondeo de veh\u00edculos enviar\u00e1 un comando remoto a su veh\u00edculo cada 2 horas para obtener nuevos datos del sensor. Sin sondeo del veh\u00edculo, los nuevos datos del sensor solo se reciben cuando el veh\u00edculo env\u00eda datos autom\u00e1ticamente (normalmente despu\u00e9s de apagar el motor).", + "title": "Opciones de Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/et.json b/homeassistant/components/subaru/translations/et.json new file mode 100644 index 00000000000..30dd690f849 --- /dev/null +++ b/homeassistant/components/subaru/translations/et.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud", + "cannot_connect": "\u00dchendamine nurjus" + }, + "error": { + "bad_pin_format": "PIN-kood peaks olema 4-kohaline", + "cannot_connect": "\u00dchendamine nurjus", + "incorrect_pin": "Vale PIN-kood", + "invalid_auth": "Vigane autentimine", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Sisesta oma MySubaru PIN-kood\n M\u00c4RKUS. K\u00f5igil kontol olevatel s\u00f5idukitel peab olema sama PIN-kood", + "title": "Subaru Starlinki konfiguratsioon" + }, + "user": { + "data": { + "country": "Vali riik", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta oma MySubaru mandaat\n M\u00c4RKUS. Esmane seadistamine v\u00f5ib v\u00f5tta kuni 30 sekundit", + "title": "Subaru Starlinki konfiguratsioon" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Luba s\u00f5iduki k\u00fcsitlus" + }, + "description": "Kui see on lubatud, saadetakse k\u00fcsitlus ts\u00f5idukile iga kahe tunni j\u00e4rel, et saada uusi anduriandmeid. Ilma s\u00f5iduki valimiseta saadakse uusi anduriandmeid ainult siis, kui s\u00f5iduk automaatselt andmeid edastab (tavaliselt p\u00e4rast mootori seiskamist).", + "title": "Subaru Starlinki valikud" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/fr.json b/homeassistant/components/subaru/translations/fr.json new file mode 100644 index 00000000000..25544534297 --- /dev/null +++ b/homeassistant/components/subaru/translations/fr.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion" + }, + "error": { + "bad_pin_format": "Le code PIN doit \u00eatre compos\u00e9 de 4 chiffres", + "cannot_connect": "\u00c9chec de connexion", + "incorrect_pin": "PIN incorrect", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Veuillez entrer votre NIP MySubaru\nREMARQUE : Tous les v\u00e9hicules en compte doivent avoir le m\u00eame NIP", + "title": "Configuration de Subaru Starlink" + }, + "user": { + "data": { + "country": "Choisissez le pays", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez saisir vos identifiants MySubaru\n REMARQUE: la configuration initiale peut prendre jusqu'\u00e0 30 secondes", + "title": "Configuration de Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Activer l'interrogation des v\u00e9hicules" + }, + "description": "Lorsqu'elle est activ\u00e9e, l'interrogation du v\u00e9hicule enverra une commande \u00e0 distance \u00e0 votre v\u00e9hicule toutes les 2 heures pour obtenir de nouvelles donn\u00e9es de capteur. Sans interrogation du v\u00e9hicule, les nouvelles donn\u00e9es de capteur ne sont re\u00e7ues que lorsque le v\u00e9hicule pousse automatiquement les donn\u00e9es (normalement apr\u00e8s l'arr\u00eat du moteur).", + "title": "Options de Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/it.json b/homeassistant/components/subaru/translations/it.json new file mode 100644 index 00000000000..6dbb0702f46 --- /dev/null +++ b/homeassistant/components/subaru/translations/it.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi" + }, + "error": { + "bad_pin_format": "Il PIN deve essere di 4 cifre", + "cannot_connect": "Impossibile connettersi", + "incorrect_pin": "PIN errato", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Inserisci il tuo PIN MySubaru\nNOTA: tutti i veicoli nell'account devono avere lo stesso PIN", + "title": "Configurazione Subaru Starlink" + }, + "user": { + "data": { + "country": "Seleziona il paese", + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le tue credenziali MySubaru\nNOTA: la configurazione iniziale pu\u00f2 richiedere fino a 30 secondi", + "title": "Configurazione Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Abilita l'interrogazione del veicolo" + }, + "description": "Quando abilitata, l'interrogazione del veicolo invier\u00e0 un comando remoto al tuo veicolo ogni 2 ore per ottenere nuovi dati del sensore. Senza l'interrogazione del veicolo, i nuovi dati del sensore verranno ricevuti solo quando il veicolo invier\u00e0 automaticamente i dati (normalmente dopo lo spegnimento del motore).", + "title": "Opzioni Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/nl.json b/homeassistant/components/subaru/translations/nl.json new file mode 100644 index 00000000000..5a9bd4119ff --- /dev/null +++ b/homeassistant/components/subaru/translations/nl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken" + }, + "error": { + "bad_pin_format": "De pincode moet uit 4 cijfers bestaan", + "cannot_connect": "Kan geen verbinding maken", + "incorrect_pin": "Onjuiste PIN", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "title": "Subaru Starlink Configuratie" + }, + "user": { + "data": { + "country": "Selecteer land", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Subaru Starlink-configuratie" + } + } + }, + "options": { + "step": { + "init": { + "title": "Subaru Starlink-opties" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/no.json b/homeassistant/components/subaru/translations/no.json new file mode 100644 index 00000000000..25b0f7bec29 --- /dev/null +++ b/homeassistant/components/subaru/translations/no.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes" + }, + "error": { + "bad_pin_format": "PIN-koden skal best\u00e5 av fire sifre", + "cannot_connect": "Tilkobling mislyktes", + "incorrect_pin": "Feil PIN", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Vennligst skriv inn MySubaru PIN-koden\n MERKNAD: Alle kj\u00f8ret\u00f8yer som er kontoen m\u00e5 ha samme PIN-kode", + "title": "Subaru Starlink-konfigurasjon" + }, + "user": { + "data": { + "country": "Velg land", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn MySubaru-legitimasjonen din\n MERK: F\u00f8rste oppsett kan ta opptil 30 sekunder", + "title": "Subaru Starlink-konfigurasjon" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Aktiver polling av kj\u00f8ret\u00f8y" + }, + "description": "N\u00e5r dette er aktivert, sender polling av kj\u00f8ret\u00f8y en fjernkommando til kj\u00f8ret\u00f8yet annenhver time for \u00e5 skaffe nye sensordata. Uten kj\u00f8ret\u00f8yoppm\u00e5ling mottas nye sensordata bare n\u00e5r kj\u00f8ret\u00f8yet automatisk skyver data (normalt etter motorstans).", + "title": "Subaru Starlink alternativer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/pl.json b/homeassistant/components/subaru/translations/pl.json new file mode 100644 index 00000000000..99415cdeea7 --- /dev/null +++ b/homeassistant/components/subaru/translations/pl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "error": { + "bad_pin_format": "PIN powinien sk\u0142ada\u0107 si\u0119 z 4 cyfr", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "incorrect_pin": "Nieprawid\u0142owy PIN", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "Wprowad\u017a sw\u00f3j PIN dla MySubaru\nUWAGA: Wszystkie pojazdy na koncie musz\u0105 mie\u0107 ten sam kod PIN", + "title": "Konfiguracja Subaru Starlink" + }, + "user": { + "data": { + "country": "Wybierz kraj", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce MySubaru\nUWAGA: Pocz\u0105tkowa konfiguracja mo\u017ce zaj\u0105\u0107 do 30 sekund", + "title": "Konfiguracja Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "W\u0142\u0105cz odpytywanie pojazdu" + }, + "description": "Po w\u0142\u0105czeniu, odpytywanie pojazdu b\u0119dzie co 2 godziny wysy\u0142a\u0107 zdalne polecenie do pojazdu w celu uzyskania nowych danych z czujnika. Bez odpytywania pojazdu, nowe dane z czujnika s\u0105 odbierane tylko wtedy, gdy pojazd automatycznie przesy\u0142a dane (zwykle po wy\u0142\u0105czeniu silnika).", + "title": "Opcje Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/ru.json b/homeassistant/components/subaru/translations/ru.json new file mode 100644 index 00000000000..7e3fbce6e38 --- /dev/null +++ b/homeassistant/components/subaru/translations/ru.json @@ -0,0 +1,44 @@ +{ + "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.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "error": { + "bad_pin_format": "PIN-\u043a\u043e\u0434 \u0434\u043e\u043b\u0436\u0435\u043d \u0441\u043e\u0441\u0442\u043e\u044f\u0442\u044c \u0438\u0437 4 \u0446\u0438\u0444\u0440.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "incorrect_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434.", + "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": { + "pin": { + "data": { + "pin": "PIN-\u043a\u043e\u0434" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434 MySubaru.\n\u0412\u0441\u0435 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u0438 \u0432 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043e\u043b\u0436\u043d\u044b \u0438\u043c\u0435\u0442\u044c \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u0439 PIN-\u043a\u043e\u0434.", + "title": "Subaru Starlink" + }, + "user": { + "data": { + "country": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043d\u0443", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 MySubaru.\n\u041f\u0435\u0440\u0432\u043e\u043d\u0430\u0447\u0430\u043b\u044c\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u0434\u043e 30 \u0441\u0435\u043a\u0443\u043d\u0434.", + "title": "Subaru Starlink" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u043f\u0440\u043e\u0441 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u0435\u0439" + }, + "description": "\u0415\u0441\u043b\u0438 \u044d\u0442\u043e\u0442 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d, Home Assistant \u0431\u0443\u0434\u0435\u0442 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u0443 \u043d\u0430 \u0412\u0430\u0448 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u044c \u043a\u0430\u0436\u0434\u044b\u0435 2 \u0447\u0430\u0441\u0430 \u0434\u043b\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043d\u043e\u0432\u044b\u0445 \u0434\u0430\u043d\u043d\u044b\u0445. \u0411\u0435\u0437 \u043e\u043f\u0440\u043e\u0441\u0430 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430 \u0434\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0442\u044c\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u043a\u043e\u0433\u0434\u0430 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u044c \u0441\u0430\u043c\u043e\u0441\u0442\u043e\u044f\u0442\u0435\u043b\u044c\u043d\u043e \u0438\u043d\u0438\u0446\u0438\u0438\u0440\u0443\u0435\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0443 \u0434\u0430\u043d\u043d\u044b\u0445 (\u043e\u0431\u044b\u0447\u043d\u043e \u043f\u043e\u0441\u043b\u0435 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0434\u0432\u0438\u0433\u0430\u0442\u0435\u043b\u044f).", + "title": "Subaru Starlink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/zh-Hant.json b/homeassistant/components/subaru/translations/zh-Hant.json new file mode 100644 index 00000000000..22eaa589fe2 --- /dev/null +++ b/homeassistant/components/subaru/translations/zh-Hant.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "error": { + "bad_pin_format": "PIN \u78bc\u61c9\u8a72\u70ba 4 \u4f4d\u6578\u5b57", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "incorrect_pin": "PIN \u78bc\u932f\u8aa4", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "\u8acb\u8f38\u5165 MySubaru PIN \u78bc\n\u6ce8\u610f\uff1a\u6240\u4ee5\u5e33\u865f\u5167\u8eca\u8f1b\u90fd\u5fc5\u9808\u4f7f\u7528\u76f8\u540c PIN \u78bc", + "title": "Subaru Starlink \u8a2d\u5b9a" + }, + "user": { + "data": { + "country": "\u9078\u64c7\u570b\u5bb6", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165 MySubaru \u8a8d\u8b49\n\u6ce8\u610f\uff1a\u555f\u59cb\u8a2d\u5b9a\u5927\u7d04\u9700\u8981 30 \u79d2", + "title": "Subaru Starlink \u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "\u958b\u555f\u8eca\u8f1b\u8cc7\u6599\u4e0b\u8f09" + }, + "description": "\u958b\u555f\u5f8c\uff0c\u5c07\u6703\u6bcf 2 \u5c0f\u6642\u50b3\u9001\u9060\u7aef\u547d\u4ee4\u81f3\u8eca\u8f1b\u4ee5\u7372\u5f97\u6700\u65b0\u50b3\u611f\u5668\u8cc7\u6599\u3002\u5982\u679c\u6c92\u6709\u958b\u555f\uff0c\u50b3\u611f\u5668\u65b0\u8cc7\u6599\u50c5\u6703\u65bc\u8eca\u8f1b\u81ea\u52d5\u63a8\u9001\u8cc7\u6599\u6642\u63a5\u6536\uff08\u901a\u5e38\u70ba\u5f15\u64ce\u7184\u706b\u4e4b\u5f8c\uff09\u3002", + "title": "Subaru Starlink \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 5128a49d8b7..2650bd61bfb 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change_event @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, - async_add_entities: Callable[[Sequence[Entity], bool], None], + async_add_entities: Callable[[Sequence[Entity]], None], discovery_info: Optional[DiscoveryInfoType] = None, ) -> None: """Initialize Light Switch platform.""" @@ -53,8 +53,7 @@ async def async_setup_platform( config[CONF_ENTITY_ID], unique_id, ) - ], - True, + ] ) @@ -66,9 +65,7 @@ class LightSwitch(LightEntity): self._name = name self._switch_entity_id = switch_entity_id self._unique_id = unique_id - self._is_on = False - self._available = False - self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = None + self._switch_state: Optional[State] = None @property def name(self) -> str: @@ -78,12 +75,16 @@ class LightSwitch(LightEntity): @property def is_on(self) -> bool: """Return true if light switch is on.""" - return self._is_on + assert self._switch_state is not None + return self._switch_state.state == STATE_ON @property def available(self) -> bool: """Return true if light switch is on.""" - return self._available + return ( + self._switch_state is not None + and self._switch_state.state != STATE_UNAVAILABLE + ) @property def should_poll(self) -> bool: @@ -117,33 +118,20 @@ class LightSwitch(LightEntity): context=self._context, ) - async def async_update(self): - """Query the switch in this light switch and determine the state.""" - switch_state = self.hass.states.get(self._switch_entity_id) - - if switch_state is None: - self._available = False - return - - self._is_on = switch_state.state == STATE_ON - self._available = switch_state.state != STATE_UNAVAILABLE - async def async_added_to_hass(self) -> None: """Register callbacks.""" + assert self.hass is not None + self._switch_state = self.hass.states.get(self._switch_entity_id) @callback def async_state_changed_listener(*_: Any) -> None: """Handle child updates.""" - self.async_schedule_update_ha_state(True) + assert self.hass is not None + self._switch_state = self.hass.states.get(self._switch_entity_id) + self.async_write_ha_state() - assert self.hass is not None - self._async_unsub_state_changed = async_track_state_change_event( - self.hass, [self._switch_entity_id], async_state_changed_listener + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._switch_entity_id], async_state_changed_listener + ) ) - - async def async_will_remove_from_hass(self): - """Handle removal from Home Assistant.""" - if self._async_unsub_state_changed is not None: - self._async_unsub_state_changed() - self._async_unsub_state_changed = None - self._available = False diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index 74dda2ddf4f..de45995797f 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -1,22 +1,13 @@ # Describes the format for available switch services turn_on: - description: Turn a switch on. - fields: - entity_id: - description: Name(s) of entities to turn on - example: "switch.living_room" + description: Turn a switch on + target: turn_off: - description: Turn a switch off. - fields: - entity_id: - description: Name(s) of entities to turn off. - example: "switch.living_room" + description: Turn a switch off + target: toggle: - description: Toggles a switch state. - fields: - entity_id: - description: Name(s) of entities to toggle. - example: "switch.living_room" + description: Toggles a switch state + target: diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 244ed708cc7..d081b3331c7 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -8,7 +8,7 @@ from aioswitcher.bridge import SwitcherV2Bridge import voluptuous as vol from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "switcher_kis" -CONF_DEVICE_ID = "device_id" CONF_DEVICE_PASSWORD = "device_password" CONF_PHONE_ID = "phone_id" @@ -48,7 +47,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """Set up the switcher component.""" - phone_id = config[DOMAIN][CONF_PHONE_ID] device_id = config[DOMAIN][CONF_DEVICE_ID] device_password = config[DOMAIN][CONF_DEVICE_PASSWORD] diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 5e75a0e6090..6b4b5026c2f 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -62,7 +62,6 @@ async def async_setup_platform( async def async_set_auto_off_service(entity, service_call: ServiceCallType) -> None: """Use for handling setting device auto-off service calls.""" - async with SwitcherV2Api( hass.loop, device_data.ip_addr, @@ -76,7 +75,6 @@ async def async_setup_platform( entity, service_call: ServiceCallType ) -> None: """Use for handling turning device on with a timer service calls.""" - async with SwitcherV2Api( hass.loop, device_data.ip_addr, @@ -133,7 +131,6 @@ class SwitcherControl(SwitchEntity): @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._state == SWITCHER_STATE_ON @property @@ -144,7 +141,6 @@ class SwitcherControl(SwitchEntity): @property def device_state_attributes(self) -> Dict: """Return the optional state attributes.""" - attribs = {} for prop, attr in DEVICE_PROPERTIES_TO_HA_ATTRIBUTES.items(): @@ -157,7 +153,6 @@ class SwitcherControl(SwitchEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._state in [SWITCHER_STATE_ON, SWITCHER_STATE_OFF] async def async_added_to_hass(self) -> None: @@ -188,7 +183,6 @@ class SwitcherControl(SwitchEntity): async def _control_device(self, send_on: bool) -> None: """Turn the entity on or off.""" - response: SwitcherV2ControlResponseMSG = None async with SwitcherV2Api( self.hass.loop, diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index cbdd46b4a6a..83f044d8ebc 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -63,9 +63,7 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) # https://github.com/PyCQA/pylint/issues/3167 - self.context["title_placeholders"] = { # pylint: disable=no-member - CONF_NAME: self.name - } + self.context["title_placeholders"] = {CONF_NAME: self.name} return await self.async_step_confirm() async def async_step_confirm(self, user_input=None): diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json index 0164fdf6ddc..67f50e84a98 100644 --- a/homeassistant/components/syncthru/strings.json +++ b/homeassistant/components/syncthru/strings.json @@ -12,7 +12,7 @@ "step": { "confirm": { "data": { - "name": "[%key:component::syncthru::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "url": "[%key:component::syncthru::config::step::user::data::url%]" } }, diff --git a/homeassistant/components/syncthru/translations/nl.json b/homeassistant/components/syncthru/translations/nl.json index 349b4b2818e..799e19ea371 100644 --- a/homeassistant/components/syncthru/translations/nl.json +++ b/homeassistant/components/syncthru/translations/nl.json @@ -8,10 +8,16 @@ }, "flow_title": "Samsung SyncThru Printer: {name}", "step": { - "user": { + "confirm": { "data": { "name": "Naam" } + }, + "user": { + "data": { + "name": "Naam", + "url": "Webinterface URL" + } } } } diff --git a/homeassistant/components/synology/__init__.py b/homeassistant/components/synology/__init__.py deleted file mode 100644 index 0ab4b45e298..00000000000 --- a/homeassistant/components/synology/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The synology component.""" diff --git a/homeassistant/components/synology/camera.py b/homeassistant/components/synology/camera.py deleted file mode 100644 index 4417f72918d..00000000000 --- a/homeassistant/components/synology/camera.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Support for Synology Surveillance Station Cameras.""" -from functools import partial -import logging - -import requests -from synology.surveillance_station import SurveillanceStation -import voluptuous as vol - -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_URL, - CONF_USERNAME, - CONF_VERIFY_SSL, - CONF_WHITELIST, -) -from homeassistant.helpers.aiohttp_client import ( - async_aiohttp_proxy_web, - async_get_clientsession, -) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Synology Camera" -DEFAULT_TIMEOUT = 5 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up a Synology IP Camera.""" - _LOGGER.warning( - "The Synology integration is deprecated." - " Please use the Synology DSM integration" - " (https://www.home-assistant.io/integrations/synology_dsm/) instead." - " This integration will be removed in version 0.118.0." - ) - - verify_ssl = config.get(CONF_VERIFY_SSL) - timeout = config.get(CONF_TIMEOUT) - - try: - surveillance = await hass.async_add_executor_job( - partial( - SurveillanceStation, - config.get(CONF_URL), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - verify_ssl=verify_ssl, - timeout=timeout, - ) - ) - except (requests.exceptions.RequestException, ValueError): - _LOGGER.exception("Error when initializing SurveillanceStation") - return False - - cameras = surveillance.get_all_cameras() - - # add cameras - devices = [] - for camera in cameras: - if not config[CONF_WHITELIST] or camera.name in config[CONF_WHITELIST]: - device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) - devices.append(device) - - async_add_entities(devices) - - -class SynologyCamera(Camera): - """An implementation of a Synology NAS based IP camera.""" - - def __init__(self, surveillance, camera_id, verify_ssl): - """Initialize a Synology Surveillance Station camera.""" - super().__init__() - self._surveillance = surveillance - self._camera_id = camera_id - self._verify_ssl = verify_ssl - self._camera = self._surveillance.get_camera(camera_id) - self._motion_setting = self._surveillance.get_motion_setting(camera_id) - self.is_streaming = self._camera.is_enabled - - def camera_image(self): - """Return bytes of camera image.""" - return self._surveillance.get_camera_image(self._camera_id) - - async def handle_async_mjpeg_stream(self, request): - """Return a MJPEG stream image response directly from the camera.""" - streaming_url = self._camera.video_stream_url - - websession = async_get_clientsession(self.hass, self._verify_ssl) - stream_coro = websession.get(streaming_url) - - return await async_aiohttp_proxy_web(self.hass, request, stream_coro) - - @property - def name(self): - """Return the name of this device.""" - return self._camera.name - - @property - def is_recording(self): - """Return true if the device is recording.""" - return self._camera.is_recording - - @property - def should_poll(self): - """Update the recording state periodically.""" - return True - - def update(self): - """Update the status of the camera.""" - self._surveillance.update() - self._camera = self._surveillance.get_camera(self._camera.camera_id) - self._motion_setting = self._surveillance.get_motion_setting( - self._camera.camera_id - ) - self.is_streaming = self._camera.is_enabled - - @property - def motion_detection_enabled(self): - """Return the camera motion detection status.""" - return self._motion_setting.is_enabled - - def enable_motion_detection(self): - """Enable motion detection in the camera.""" - self._surveillance.enable_motion_detection(self._camera_id) - - def disable_motion_detection(self): - """Disable motion detection in camera.""" - self._surveillance.disable_motion_detection(self._camera_id) diff --git a/homeassistant/components/synology/manifest.json b/homeassistant/components/synology/manifest.json deleted file mode 100644 index a29dccc2a78..00000000000 --- a/homeassistant/components/synology/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "synology", - "name": "Synology", - "documentation": "https://www.home-assistant.io/integrations/synology", - "requirements": ["py-synology==0.2.0"], - "codeowners": [] -} diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 06696865d03..6f0476b403c 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging from typing import Dict +import async_timeout from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem @@ -14,6 +15,7 @@ from synology_dsm.api.dsm.network import SynoDSMNetwork from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, SynologyDSMLoginFailedException, SynologyDSMRequestException, ) @@ -37,17 +39,20 @@ from homeassistant.core import ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( + CONF_DEVICE_TOKEN, CONF_SERIAL, CONF_VOLUMES, + COORDINATOR_CAMERAS, + COORDINATOR_CENTRAL, + COORDINATOR_SWITCHES, DEFAULT_SCAN_INTERVAL, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, @@ -65,6 +70,7 @@ from .const import ( STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, SYNO_API, + SYSTEM_LOADED, TEMP_SENSORS_KEYS, UNDO_UPDATE_LISTENER, UTILISATION_SENSORS, @@ -185,15 +191,14 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): try: await api.async_setup() except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: - _LOGGER.debug("async_setup_entry - Unable to connect to DSM: %s", err) + _LOGGER.debug("async_setup_entry() - Unable to connect to DSM: %s", err) raise ConfigEntryNotReady from err - undo_listener = entry.add_update_listener(_async_update_listener) - hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.unique_id] = { + UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), SYNO_API: api, - UNDO_UPDATE_LISTENER: undo_listener, + SYSTEM_LOADED: True, } # Services @@ -206,6 +211,85 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): entry, data={**entry.data, CONF_MAC: network.macs} ) + async def async_coordinator_update_data_cameras(): + """Fetch all camera data from api.""" + if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: + raise UpdateFailed("System not fully loaded") + + if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: + return None + + surveillance_station = api.surveillance_station + + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job(surveillance_station.update) + except SynologyDSMAPIErrorException as err: + _LOGGER.debug( + "async_coordinator_update_data_cameras() - exception: %s", err + ) + raise UpdateFailed(f"Error communicating with API: {err}") from err + + return { + "cameras": { + camera.id: camera for camera in surveillance_station.get_all_cameras() + } + } + + async def async_coordinator_update_data_central(): + """Fetch all device and sensor data from api.""" + try: + await api.async_update() + except Exception as err: + _LOGGER.debug( + "async_coordinator_update_data_central() - exception: %s", err + ) + raise UpdateFailed(f"Error communicating with API: {err}") from err + return None + + async def async_coordinator_update_data_switches(): + """Fetch all switch data from api.""" + if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: + raise UpdateFailed("System not fully loaded") + if SynoSurveillanceStation.HOME_MODE_API_KEY not in api.dsm.apis: + return None + + surveillance_station = api.surveillance_station + + return { + "switches": { + "home_mode": await hass.async_add_executor_job( + surveillance_station.get_home_mode_status + ) + } + } + + hass.data[DOMAIN][entry.unique_id][COORDINATOR_CAMERAS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{entry.unique_id}_cameras", + update_method=async_coordinator_update_data_cameras, + update_interval=timedelta(seconds=30), + ) + + hass.data[DOMAIN][entry.unique_id][COORDINATOR_CENTRAL] = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{entry.unique_id}_central", + update_method=async_coordinator_update_data_central, + update_interval=timedelta( + minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + ) + + hass.data[DOMAIN][entry.unique_id][COORDINATOR_SWITCHES] = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{entry.unique_id}_switches", + update_method=async_coordinator_update_data_switches, + update_interval=timedelta(seconds=30), + ) + for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) @@ -267,10 +351,11 @@ async def _async_setup_services(hass: HomeAssistantType): _LOGGER.debug("%s DSM with serial %s", call.service, serial) dsm_api = dsm_device[SYNO_API] + dsm_device[SYSTEM_LOADED] = False if call.service == SERVICE_REBOOT: await dsm_api.async_reboot() elif call.service == SERVICE_SHUTDOWN: - await dsm_api.system.shutdown() + await dsm_api.async_shutdown() for service in SERVICES: hass.services.async_register(DOMAIN, service, service_handler) @@ -305,13 +390,6 @@ class SynoApi: self._with_upgrade = True self._with_utilisation = True - self._unsub_dispatcher = None - - @property - def signal_sensor_update(self) -> str: - """Event specific per Synology DSM entry to signal updates in sensors.""" - return f"{DOMAIN}-{self.information.serial}-sensor-update" - async def async_setup(self): """Start interacting with the NAS.""" self.dsm = SynologyDSM( @@ -322,32 +400,30 @@ class SynoApi: self._entry.data[CONF_SSL], self._entry.data[CONF_VERIFY_SSL], timeout=self._entry.options.get(CONF_TIMEOUT), - device_token=self._entry.data.get("device_token"), + device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) await self._hass.async_add_executor_job(self.dsm.login) + # check if surveillance station is used self._with_surveillance_station = bool( self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) ) + _LOGGER.debug( + "SynoAPI.async_setup() - self._with_surveillance_station:%s", + self._with_surveillance_station, + ) - self._async_setup_api_requests() + self._setup_api_requests() await self._hass.async_add_executor_job(self._fetch_device_configuration) await self.async_update() - self._unsub_dispatcher = async_track_time_interval( - self._hass, - self.async_update, - timedelta( - minutes=self._entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - ), - ) - @callback def subscribe(self, api_key, unique_id): - """Subscribe an entity from API fetches.""" + """Subscribe an entity to API fetches.""" + _LOGGER.debug( + "SynoAPI.subscribe() - api_key:%s, unique_id:%s", api_key, unique_id + ) if api_key not in self._fetching_entities: self._fetching_entities[api_key] = set() self._fetching_entities[api_key].add(unique_id) @@ -355,23 +431,35 @@ class SynoApi: @callback def unsubscribe() -> None: """Unsubscribe an entity from API fetches (when disable).""" + _LOGGER.debug( + "SynoAPI.unsubscribe() - api_key:%s, unique_id:%s", api_key, unique_id + ) self._fetching_entities[api_key].remove(unique_id) + if len(self._fetching_entities[api_key]) == 0: + self._fetching_entities.pop(api_key) return unsubscribe @callback - def _async_setup_api_requests(self): + def _setup_api_requests(self): """Determine if we should fetch each API, if one entity needs it.""" # Entities not added yet, fetch all if not self._fetching_entities: + _LOGGER.debug( + "SynoAPI._setup_api_requests() - Entities not added yet, fetch all" + ) return # Determine if we should fetch an API + self._with_system = bool(self.dsm.apis.get(SynoCoreSystem.API_KEY)) + self._with_surveillance_station = bool( + self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) + ) or bool(self.dsm.apis.get(SynoSurveillanceStation.HOME_MODE_API_KEY)) + self._with_security = bool( self._fetching_entities.get(SynoCoreSecurity.API_KEY) ) self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) - self._with_system = bool(self._fetching_entities.get(SynoCoreSystem.API_KEY)) self._with_upgrade = bool(self._fetching_entities.get(SynoCoreUpgrade.API_KEY)) self._with_utilisation = bool( self._fetching_entities.get(SynoCoreUtilization.API_KEY) @@ -379,34 +467,37 @@ class SynoApi: self._with_information = bool( self._fetching_entities.get(SynoDSMInformation.API_KEY) ) - self._with_surveillance_station = bool( - self._fetching_entities.get(SynoSurveillanceStation.CAMERA_API_KEY) - ) or bool( - self._fetching_entities.get(SynoSurveillanceStation.HOME_MODE_API_KEY) - ) # Reset not used API, information is not reset since it's used in device_info if not self._with_security: + _LOGGER.debug("SynoAPI._setup_api_requests() - disable security") self.dsm.reset(self.security) self.security = None if not self._with_storage: + _LOGGER.debug("SynoAPI._setup_api_requests() - disable storage") self.dsm.reset(self.storage) self.storage = None if not self._with_system: + _LOGGER.debug("SynoAPI._setup_api_requests() - disable system") self.dsm.reset(self.system) self.system = None if not self._with_upgrade: + _LOGGER.debug("SynoAPI._setup_api_requests() - disable upgrade") self.dsm.reset(self.upgrade) self.upgrade = None if not self._with_utilisation: + _LOGGER.debug("SynoAPI._setup_api_requests() - disable utilisation") self.dsm.reset(self.utilisation) self.utilisation = None if not self._with_surveillance_station: + _LOGGER.debug( + "SynoAPI._setup_api_requests() - disable surveillance_station" + ) self.dsm.reset(self.surveillance_station) self.surveillance_station = None @@ -417,44 +508,58 @@ class SynoApi: self.network.update() if self._with_security: + _LOGGER.debug("SynoAPI._fetch_device_configuration() - fetch security") self.security = self.dsm.security if self._with_storage: + _LOGGER.debug("SynoAPI._fetch_device_configuration() - fetch storage") self.storage = self.dsm.storage if self._with_upgrade: + _LOGGER.debug("SynoAPI._fetch_device_configuration() - fetch upgrade") self.upgrade = self.dsm.upgrade if self._with_system: + _LOGGER.debug("SynoAPI._fetch_device_configuration() - fetch system") self.system = self.dsm.system if self._with_utilisation: + _LOGGER.debug("SynoAPI._fetch_device_configuration() - fetch utilisation") self.utilisation = self.dsm.utilisation if self._with_surveillance_station: + _LOGGER.debug( + "SynoAPI._fetch_device_configuration() - fetch surveillance_station" + ) self.surveillance_station = self.dsm.surveillance_station async def async_reboot(self): """Reboot NAS.""" - if not self.system: - _LOGGER.debug("async_reboot - System API not ready: %s", self) - return - await self._hass.async_add_executor_job(self.system.reboot) + try: + await self._hass.async_add_executor_job(self.system.reboot) + except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + _LOGGER.error("Reboot not possible, please try again later") + _LOGGER.debug("Exception:%s", err) async def async_shutdown(self): """Shutdown NAS.""" - if not self.system: - _LOGGER.debug("async_shutdown - System API not ready: %s", self) - return - await self._hass.async_add_executor_job(self.system.shutdown) + try: + await self._hass.async_add_executor_job(self.system.shutdown) + except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + _LOGGER.error("Shutdown not possible, please try again later") + _LOGGER.debug("Exception:%s", err) async def async_unload(self): """Stop interacting with the NAS and prepare for removal from hass.""" - self._unsub_dispatcher() + try: + await self._hass.async_add_executor_job(self.dsm.logout) + except (SynologyDSMAPIErrorException, SynologyDSMRequestException) as err: + _LOGGER.debug("Logout not possible:%s", err) async def async_update(self, now=None): """Update function for updating API information.""" - self._async_setup_api_requests() + _LOGGER.debug("SynoAPI.async_update()") + self._setup_api_requests() try: await self._hass.async_add_executor_job( self.dsm.update, self._with_information @@ -463,13 +568,12 @@ class SynoApi: _LOGGER.warning( "async_update - connection error during update, fallback by reloading the entry" ) - _LOGGER.debug("async_update - exception: %s", err) + _LOGGER.debug("SynoAPI.async_update() - exception: %s", err) await self._hass.config_entries.async_reload(self._entry.entry_id) return - async_dispatcher_send(self._hass, self.signal_sensor_update) -class SynologyDSMEntity(Entity): +class SynologyDSMBaseEntity(CoordinatorEntity): """Representation of a Synology NAS entry.""" def __init__( @@ -477,9 +581,10 @@ class SynologyDSMEntity(Entity): api: SynoApi, entity_type: str, entity_info: Dict[str, str], + coordinator: DataUpdateCoordinator, ): """Initialize the Synology DSM entity.""" - super().__init__() + super().__init__(coordinator) self._api = api self._api_key = entity_type.split(":")[0] @@ -539,30 +644,13 @@ class SynologyDSMEntity(Entity): """Return if the entity should be enabled when first added to the entity registry.""" return self._enable_default - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - - async def async_update(self): - """Only used by the generic entity update service.""" - if not self.enabled: - return - - await self._api.async_update() - async def async_added_to_hass(self): - """Register state update callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._api.signal_sensor_update, self.async_write_ha_state - ) - ) - + """Register entity for updates from API.""" self.async_on_remove(self._api.subscribe(self._api_key, self.unique_id)) + await super().async_added_to_hass() -class SynologyDSMDeviceEntity(SynologyDSMEntity): +class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): """Representation of a Synology NAS disk or volume entry.""" def __init__( @@ -570,10 +658,11 @@ class SynologyDSMDeviceEntity(SynologyDSMEntity): api: SynoApi, entity_type: str, entity_info: Dict[str, str], + coordinator: DataUpdateCoordinator, device_id: str = None, ): """Initialize the Synology DSM disk or volume entity.""" - super().__init__(api, entity_type, entity_info) + super().__init__(api, entity_type, entity_info, coordinator) self._device_id = device_id self._device_name = None self._device_manufacturer = None diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 69f217a4b4e..6e89f3d7a84 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS from homeassistant.helpers.typing import HomeAssistantType -from . import SynologyDSMDeviceEntity, SynologyDSMEntity +from . import SynologyDSMBaseEntity, SynologyDSMDeviceEntity from .const import ( + COORDINATOR_CENTRAL, DOMAIN, SECURITY_BINARY_SENSORS, STORAGE_DISK_BINARY_SENSORS, @@ -21,18 +22,20 @@ async def async_setup_entry( ) -> None: """Set up the Synology NAS binary sensor.""" - api = hass.data[DOMAIN][entry.unique_id][SYNO_API] + data = hass.data[DOMAIN][entry.unique_id] + api = data[SYNO_API] + coordinator = data[COORDINATOR_CENTRAL] entities = [ SynoDSMSecurityBinarySensor( - api, sensor_type, SECURITY_BINARY_SENSORS[sensor_type] + api, sensor_type, SECURITY_BINARY_SENSORS[sensor_type], coordinator ) for sensor_type in SECURITY_BINARY_SENSORS ] entities += [ SynoDSMUpgradeBinarySensor( - api, sensor_type, UPGRADE_BINARY_SENSORS[sensor_type] + api, sensor_type, UPGRADE_BINARY_SENSORS[sensor_type], coordinator ) for sensor_type in UPGRADE_BINARY_SENSORS ] @@ -42,7 +45,11 @@ async def async_setup_entry( for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): entities += [ SynoDSMStorageBinarySensor( - api, sensor_type, STORAGE_DISK_BINARY_SENSORS[sensor_type], disk + api, + sensor_type, + STORAGE_DISK_BINARY_SENSORS[sensor_type], + coordinator, + disk, ) for sensor_type in STORAGE_DISK_BINARY_SENSORS ] @@ -50,7 +57,7 @@ async def async_setup_entry( async_add_entities(entities) -class SynoDSMSecurityBinarySensor(SynologyDSMEntity, BinarySensorEntity): +class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): """Representation a Synology Security binary sensor.""" @property @@ -78,7 +85,7 @@ class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): return getattr(self._api.storage, self.entity_type)(self._device_id) -class SynoDSMUpgradeBinarySensor(SynologyDSMEntity, BinarySensorEntity): +class SynoDSMUpgradeBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): """Representation a Synology Upgrade binary sensor.""" @property diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 1dfd8ff945b..c0e0ded72ed 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -1,15 +1,21 @@ """Support for Synology DSM cameras.""" +import logging from typing import Dict from synology_dsm.api.surveillance_station import SynoSurveillanceStation -from synology_dsm.api.surveillance_station.camera import SynoCamera +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMRequestException, +) from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import SynoApi, SynologyDSMEntity +from . import SynoApi, SynologyDSMBaseEntity from .const import ( + COORDINATOR_CAMERAS, DOMAIN, ENTITY_CLASS, ENTITY_ENABLE, @@ -19,50 +25,72 @@ from .const import ( SYNO_API, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: - """Set up the Synology NAS binary sensor.""" + """Set up the Synology NAS cameras.""" - api = hass.data[DOMAIN][entry.unique_id][SYNO_API] + data = hass.data[DOMAIN][entry.unique_id] + api = data[SYNO_API] if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: return - surveillance_station = api.surveillance_station - await hass.async_add_executor_job(surveillance_station.update) - cameras = surveillance_station.get_all_cameras() - entities = [SynoDSMCamera(api, camera) for camera in cameras] + # initial data fetch + coordinator = data[COORDINATOR_CAMERAS] + await coordinator.async_refresh() - async_add_entities(entities) + async_add_entities( + SynoDSMCamera(api, coordinator, camera_id) + for camera_id in coordinator.data["cameras"] + ) -class SynoDSMCamera(SynologyDSMEntity, Camera): +class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Representation a Synology camera.""" - def __init__(self, api: SynoApi, camera: SynoCamera): + def __init__( + self, api: SynoApi, coordinator: DataUpdateCoordinator, camera_id: int + ): """Initialize a Synology camera.""" super().__init__( api, - f"{SynoSurveillanceStation.CAMERA_API_KEY}:{camera.id}", + f"{SynoSurveillanceStation.CAMERA_API_KEY}:{camera_id}", { - ENTITY_NAME: camera.name, + ENTITY_NAME: coordinator.data["cameras"][camera_id].name, + ENTITY_ENABLE: coordinator.data["cameras"][camera_id].is_enabled, ENTITY_CLASS: None, ENTITY_ICON: None, - ENTITY_ENABLE: True, ENTITY_UNIT: None, }, + coordinator, ) - self._camera = camera + Camera.__init__(self) + + self._camera_id = camera_id + self._api = api + + @property + def camera_data(self): + """Camera data.""" + return self.coordinator.data["cameras"][self._camera_id] @property def device_info(self) -> Dict[str, any]: """Return the device information.""" return { - "identifiers": {(DOMAIN, self._api.information.serial, self._camera.id)}, - "name": self._camera.name, - "model": self._camera.model, + "identifiers": { + ( + DOMAIN, + self._api.information.serial, + self.camera_data.id, + ) + }, + "name": self.camera_data.name, + "model": self.camera_data.model, "via_device": ( DOMAIN, self._api.information.serial, @@ -73,7 +101,7 @@ class SynoDSMCamera(SynologyDSMEntity, Camera): @property def available(self) -> bool: """Return the availability of the camera.""" - return self._camera.is_enabled + return self.camera_data.is_enabled and self.coordinator.last_update_success @property def supported_features(self) -> int: @@ -83,29 +111,57 @@ class SynoDSMCamera(SynologyDSMEntity, Camera): @property def is_recording(self): """Return true if the device is recording.""" - return self._camera.is_recording + return self.camera_data.is_recording @property def motion_detection_enabled(self): """Return the camera motion detection status.""" - return self._camera.is_motion_detection_enabled + return self.camera_data.is_motion_detection_enabled def camera_image(self) -> bytes: """Return bytes of camera image.""" + _LOGGER.debug( + "SynoDSMCamera.camera_image(%s)", + self.camera_data.name, + ) if not self.available: return None - return self._api.surveillance_station.get_camera_image(self._camera.id) + try: + return self._api.surveillance_station.get_camera_image(self._camera_id) + except ( + SynologyDSMAPIErrorException, + SynologyDSMRequestException, + ConnectionRefusedError, + ) as err: + _LOGGER.debug( + "SynoDSMCamera.camera_image(%s) - Exception:%s", + self.camera_data.name, + err, + ) + return None async def stream_source(self) -> str: """Return the source of the stream.""" + _LOGGER.debug( + "SynoDSMCamera.stream_source(%s)", + self.camera_data.name, + ) if not self.available: return None - return self._camera.live_view.rtsp + return self.camera_data.live_view.rtsp def enable_motion_detection(self): """Enable motion detection in the camera.""" - self._api.surveillance_station.enable_motion_detection(self._camera.id) + _LOGGER.debug( + "SynoDSMCamera.enable_motion_detection(%s)", + self.camera_data.name, + ) + self._api.surveillance_station.enable_motion_detection(self._camera_id) def disable_motion_detection(self): """Disable motion detection in camera.""" - self._api.surveillance_station.disable_motion_detection(self._camera.id) + _LOGGER.debug( + "SynoDSMCamera.disable_motion_detection(%s)", + self.camera_data.name, + ) + self._api.surveillance_station.disable_motion_detection(self._camera_id) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 5a1ab53b3f7..e7b510bb399 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -31,6 +31,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( + CONF_DEVICE_TOKEN, CONF_VOLUMES, DEFAULT_PORT, DEFAULT_PORT_SSL, @@ -180,7 +181,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_MAC: api.network.macs, } if otp_code: - config_data["device_token"] = api.device_token + config_data[CONF_DEVICE_TOKEN] = api.device_token if user_input.get(CONF_DISKS): config_data[CONF_DISKS] = user_input[CONF_DISKS] if user_input.get(CONF_VOLUMES): @@ -208,7 +209,6 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_NAME: friendly_name, CONF_HOST: parsed_url.hostname, } - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index ba1a8034223..97f378c8e76 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -19,6 +19,10 @@ from homeassistant.const import ( DOMAIN = "synology_dsm" PLATFORMS = ["binary_sensor", "camera", "sensor", "switch"] +COORDINATOR_CAMERAS = "coordinator_cameras" +COORDINATOR_CENTRAL = "coordinator_central" +COORDINATOR_SWITCHES = "coordinator_switches" +SYSTEM_LOADED = "system_loaded" # Entry keys SYNO_API = "syno_api" @@ -27,6 +31,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" +CONF_DEVICE_TOKEN = "device_token" DEFAULT_USE_SSL = True DEFAULT_VERIFY_SSL = False @@ -36,6 +41,7 @@ DEFAULT_PORT_SSL = 5001 DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = 10 # sec +ENTITY_UNIT_LOAD = "load" ENTITY_NAME = "name" ENTITY_UNIT = "unit" @@ -94,50 +100,50 @@ STORAGE_DISK_BINARY_SENSORS = { # Sensors UTILISATION_SENSORS = { f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { - ENTITY_NAME: "CPU Load (Other)", + ENTITY_NAME: "CPU Utilization (Other)", ENTITY_UNIT: PERCENTAGE, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: False, }, f"{SynoCoreUtilization.API_KEY}:cpu_user_load": { - ENTITY_NAME: "CPU Load (User)", + ENTITY_NAME: "CPU Utilization (User)", ENTITY_UNIT: PERCENTAGE, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: True, }, f"{SynoCoreUtilization.API_KEY}:cpu_system_load": { - ENTITY_NAME: "CPU Load (System)", + ENTITY_NAME: "CPU Utilization (System)", ENTITY_UNIT: PERCENTAGE, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: False, }, f"{SynoCoreUtilization.API_KEY}:cpu_total_load": { - ENTITY_NAME: "CPU Load (Total)", + ENTITY_NAME: "CPU Utilization (Total)", ENTITY_UNIT: PERCENTAGE, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: True, }, f"{SynoCoreUtilization.API_KEY}:cpu_1min_load": { - ENTITY_NAME: "CPU Load (1 min)", - ENTITY_UNIT: PERCENTAGE, + ENTITY_NAME: "CPU Load Averarge (1 min)", + ENTITY_UNIT: ENTITY_UNIT_LOAD, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: False, }, f"{SynoCoreUtilization.API_KEY}:cpu_5min_load": { - ENTITY_NAME: "CPU Load (5 min)", - ENTITY_UNIT: PERCENTAGE, + ENTITY_NAME: "CPU Load Averarge (5 min)", + ENTITY_UNIT: ENTITY_UNIT_LOAD, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: True, }, f"{SynoCoreUtilization.API_KEY}:cpu_15min_load": { - ENTITY_NAME: "CPU Load (15 min)", - ENTITY_UNIT: PERCENTAGE, + ENTITY_NAME: "CPU Load Averarge (15 min)", + ENTITY_UNIT: ENTITY_UNIT_LOAD, ENTITY_ICON: "mdi:chip", ENTITY_CLASS: None, ENTITY_ENABLE: True, diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 31013451682..79350ce89d3 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -13,12 +13,15 @@ from homeassistant.const import ( ) from homeassistant.helpers.temperature import display_temp from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow -from . import SynoApi, SynologyDSMDeviceEntity, SynologyDSMEntity +from . import SynoApi, SynologyDSMBaseEntity, SynologyDSMDeviceEntity from .const import ( CONF_VOLUMES, + COORDINATOR_CENTRAL, DOMAIN, + ENTITY_UNIT_LOAD, INFORMATION_SENSORS, STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, @@ -33,10 +36,14 @@ async def async_setup_entry( ) -> None: """Set up the Synology NAS Sensor.""" - api = hass.data[DOMAIN][entry.unique_id][SYNO_API] + data = hass.data[DOMAIN][entry.unique_id] + api = data[SYNO_API] + coordinator = data[COORDINATOR_CENTRAL] entities = [ - SynoDSMUtilSensor(api, sensor_type, UTILISATION_SENSORS[sensor_type]) + SynoDSMUtilSensor( + api, sensor_type, UTILISATION_SENSORS[sensor_type], coordinator + ) for sensor_type in UTILISATION_SENSORS ] @@ -45,7 +52,11 @@ async def async_setup_entry( for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids): entities += [ SynoDSMStorageSensor( - api, sensor_type, STORAGE_VOL_SENSORS[sensor_type], volume + api, + sensor_type, + STORAGE_VOL_SENSORS[sensor_type], + coordinator, + volume, ) for sensor_type in STORAGE_VOL_SENSORS ] @@ -55,20 +66,26 @@ async def async_setup_entry( for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): entities += [ SynoDSMStorageSensor( - api, sensor_type, STORAGE_DISK_SENSORS[sensor_type], disk + api, + sensor_type, + STORAGE_DISK_SENSORS[sensor_type], + coordinator, + disk, ) for sensor_type in STORAGE_DISK_SENSORS ] entities += [ - SynoDSMInfoSensor(api, sensor_type, INFORMATION_SENSORS[sensor_type]) + SynoDSMInfoSensor( + api, sensor_type, INFORMATION_SENSORS[sensor_type], coordinator + ) for sensor_type in INFORMATION_SENSORS ] async_add_entities(entities) -class SynoDSMUtilSensor(SynologyDSMEntity): +class SynoDSMUtilSensor(SynologyDSMBaseEntity): """Representation a Synology Utilisation sensor.""" @property @@ -88,6 +105,10 @@ class SynoDSMUtilSensor(SynologyDSMEntity): if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: return round(attr / 1024.0, 1) + # CPU load average + if self._unit == ENTITY_UNIT_LOAD: + return round(attr / 100, 2) + return attr @property @@ -117,12 +138,18 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity): return attr -class SynoDSMInfoSensor(SynologyDSMEntity): +class SynoDSMInfoSensor(SynologyDSMBaseEntity): """Representation a Synology information sensor.""" - def __init__(self, api: SynoApi, entity_type: str, entity_info: Dict[str, str]): + def __init__( + self, + api: SynoApi, + entity_type: str, + entity_info: Dict[str, str], + coordinator: DataUpdateCoordinator, + ): """Initialize the Synology SynoDSMInfoSensor entity.""" - super().__init__(api, entity_type, entity_info) + super().__init__(api, entity_type, entity_info, coordinator) self._previous_uptime = None self._last_boot = None diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index ee29c9f2692..998f74adf2a 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -1,4 +1,5 @@ """Support for Synology DSM switch.""" +import logging from typing import Dict from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -6,9 +7,12 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import ToggleEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import SynoApi, SynologyDSMEntity -from .const import DOMAIN, SURVEILLANCE_SWITCH, SYNO_API +from . import SynoApi, SynologyDSMBaseEntity +from .const import COORDINATOR_SWITCHES, DOMAIN, SURVEILLANCE_SWITCH, SYNO_API + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -16,16 +20,21 @@ async def async_setup_entry( ) -> None: """Set up the Synology NAS switch.""" - api = hass.data[DOMAIN][entry.unique_id][SYNO_API] + data = hass.data[DOMAIN][entry.unique_id] + api = data[SYNO_API] entities = [] if SynoSurveillanceStation.INFO_API_KEY in api.dsm.apis: info = await hass.async_add_executor_job(api.dsm.surveillance_station.get_info) version = info["data"]["CMSMinVersion"] + + # initial data fetch + coordinator = data[COORDINATOR_SWITCHES] + await coordinator.async_refresh() entities += [ SynoDSMSurveillanceHomeModeToggle( - api, sensor_type, SURVEILLANCE_SWITCH[sensor_type], version + api, sensor_type, SURVEILLANCE_SWITCH[sensor_type], version, coordinator ) for sensor_type in SURVEILLANCE_SWITCH ] @@ -33,46 +42,52 @@ async def async_setup_entry( async_add_entities(entities, True) -class SynoDSMSurveillanceHomeModeToggle(SynologyDSMEntity, ToggleEntity): +class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" def __init__( - self, api: SynoApi, entity_type: str, entity_info: Dict[str, str], version: str + self, + api: SynoApi, + entity_type: str, + entity_info: Dict[str, str], + version: str, + coordinator: DataUpdateCoordinator, ): """Initialize a Synology Surveillance Station Home Mode.""" super().__init__( api, entity_type, entity_info, + coordinator, ) self._version = version - self._state = None @property def is_on(self) -> bool: """Return the state.""" - if self.entity_type == "home_mode": - return self._state - return None + return self.coordinator.data["switches"][self.entity_type] - @property - def should_poll(self) -> bool: - """No polling needed.""" - return True - - async def async_update(self): - """Update the toggle state.""" - self._state = await self.hass.async_add_executor_job( - self._api.surveillance_station.get_home_mode_status - ) - - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn on Home mode.""" - self._api.surveillance_station.set_home_mode(True) + _LOGGER.debug( + "SynoDSMSurveillanceHomeModeToggle.turn_on(%s)", + self._api.information.serial, + ) + await self.hass.async_add_executor_job( + self._api.dsm.surveillance_station.set_home_mode, True + ) + await self.coordinator.async_request_refresh() - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn off Home mode.""" - self._api.surveillance_station.set_home_mode(False) + _LOGGER.debug( + "SynoDSMSurveillanceHomeModeToggle.turn_off(%s)", + self._api.information.serial, + ) + await self.hass.async_add_executor_job( + self._api.dsm.surveillance_station.set_home_mode, False + ) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: diff --git a/homeassistant/components/synology_dsm/translations/ko.json b/homeassistant/components/synology_dsm/translations/ko.json index 6989f6515a1..efc20dbe03f 100644 --- a/homeassistant/components/synology_dsm/translations/ko.json +++ b/homeassistant/components/synology_dsm/translations/ko.json @@ -1,12 +1,14 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "missing_data": "\ub204\ub77d\ub41c \ub370\uc774\ud130: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud558\uac70\ub098 \ub2e4\ub978 \uad6c\uc131\uc744 \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694", "otp_failed": "2\ub2e8\uacc4 \uc778\uc99d\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. \uc0c8\ub85c\uc6b4 \ud328\uc2a4 \ucf54\ub4dc\ub85c \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", - "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \uc790\uc138\ud55c \uc815\ubcf4\ub294 \ub85c\uadf8\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694" + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "Synology DSM: {name} ({host})", "step": { @@ -20,8 +22,9 @@ "data": { "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec NAS \uc5d0 \uc5f0\uacb0", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "description": "{name} ({host}) \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Synology DSM" @@ -31,8 +34,9 @@ "host": "\ud638\uc2a4\ud2b8", "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", - "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec NAS \uc5d0 \uc5f0\uacb0", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "title": "Synology DSM" } diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index ed3c2eea0a8..8c48b8c3fc7 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "missing_data": "\u041e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0435: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u0443\u044e \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", "otp_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0441 \u043d\u043e\u0432\u044b\u043c \u043f\u0430\u0440\u043e\u043b\u0435\u043c.", "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/system_log/services.yaml b/homeassistant/components/system_log/services.yaml index 2545d47c825..a762c31f205 100644 --- a/homeassistant/components/system_log/services.yaml +++ b/homeassistant/components/system_log/services.yaml @@ -1,15 +1,36 @@ clear: + name: Clear all description: Clear all log entries. write: + name: Write description: Write log entry. fields: message: - description: Message to log. [Required] + name: Message + description: Message to log. + required: true example: Something went wrong + selector: + text: level: - description: "Log level: debug, info, warning, error, critical. Defaults to 'error'." + name: Level + description: "Log level: debug, info, warning, error, critical." + default: error example: debug + selector: + select: + options: + - "debug" + - "info" + - "warning" + - "error" + - "critical" logger: - description: Logger name under which to log the message. Defaults to 'system_log.external'. + name: Logger + description: + Logger name under which to log the message. Defaults to + 'system_log.external'. example: mycomponent.myplatform + selector: + text: diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index e88fb4c60b8..c7fb180e6d8 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -33,8 +33,8 @@ _LOGGER = logging.getLogger(__name__) TADO_COMPONENTS = ["binary_sensor", "sensor", "climate", "water_heater"] -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) -SCAN_INTERVAL = timedelta(seconds=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) +SCAN_INTERVAL = timedelta(minutes=5) CONFIG_SCHEMA = cv.deprecated(DOMAIN) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 71b52931013..068c3a7ce93 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -183,6 +183,7 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" self._state = None + self._state_attributes = None self._tado_zone_data = None async def async_added_to_hass(self): @@ -229,6 +230,11 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): return DEVICE_CLASS_POWER return None + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._state_attributes + @callback def _async_update_callback(self): """Update and write state.""" @@ -251,6 +257,10 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): elif self.zone_variable == "overlay": self._state = self._tado_zone_data.overlay_active + if self._tado_zone_data.overlay_active: + self._state_attributes = { + "termination": self._tado_zone_data.overlay_termination_type + } elif self.zone_variable == "early start": self._state = self._tado_zone_data.preparation @@ -260,3 +270,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): self._tado_zone_data.open_window or self._tado_zone_data.open_window_detected ) + self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index e9fefe2848b..34473a45c98 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -50,6 +50,7 @@ class TadoZoneEntity(Entity): "name": self.zone_name, "manufacturer": DEFAULT_NAME, "model": TADO_ZONE, + "suggested_area": self.zone_name, } @property diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 9b166027df3..27c7ecff411 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,7 +3,7 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": ["python-tado==0.10.0"], - "codeowners": ["@michaelarnauts", "@bdraco"], + "codeowners": ["@michaelarnauts", "@bdraco", "@noltari"], "config_flow": true, "homekit": { "models": ["tado", "AC02"] diff --git a/homeassistant/components/tado/translations/ko.json b/homeassistant/components/tado/translations/ko.json index 8982b68829b..8290e32c4c8 100644 --- a/homeassistant/components/tado/translations/ko.json +++ b/homeassistant/components/tado/translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_homes": "\uc774 Tado \uacc4\uc815\uc5d0 \uc5f0\uacb0\ub41c \uc9d1\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/tado/translations/ru.json b/homeassistant/components/tado/translations/ru.json index 8ffb14edc0e..75c83e8582b 100644 --- a/homeassistant/components/tado/translations/ru.json +++ b/homeassistant/components/tado/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "no_homes": "\u041d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u0434\u043e\u043c\u043e\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e.", "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/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index bdcc00dc764..77b4532c001 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -1,24 +1,27 @@ """Support for Tasmota fans.""" +from typing import Optional + from hatasmota import const as tasmota_const from homeassistant.components import fan from homeassistant.components.fan import FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate -HA_TO_TASMOTA_SPEED_MAP = { - fan.SPEED_OFF: tasmota_const.FAN_SPEED_OFF, - fan.SPEED_LOW: tasmota_const.FAN_SPEED_LOW, - fan.SPEED_MEDIUM: tasmota_const.FAN_SPEED_MEDIUM, - fan.SPEED_HIGH: tasmota_const.FAN_SPEED_HIGH, -} - -TASMOTA_TO_HA_SPEED_MAP = {v: k for k, v in HA_TO_TASMOTA_SPEED_MAP.items()} +ORDERED_NAMED_FAN_SPEEDS = [ + tasmota_const.FAN_SPEED_LOW, + tasmota_const.FAN_SPEED_MEDIUM, + tasmota_const.FAN_SPEED_HIGH, +] # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -56,33 +59,45 @@ class TasmotaFan( ) @property - def speed(self): - """Return the current speed.""" - return TASMOTA_TO_HA_SPEED_MAP.get(self._state) + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) @property - def speed_list(self): - """Get the list of available speeds.""" - return list(HA_TO_TASMOTA_SPEED_MAP) + def percentage(self): + """Return the current speed percentage.""" + if self._state is None: + return None + if self._state == 0: + return 0 + return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, self._state) @property def supported_features(self): """Flag supported features.""" return fan.SUPPORT_SET_SPEED - async def async_set_speed(self, speed): + async def async_set_percentage(self, percentage): """Set the speed of the fan.""" - if speed not in HA_TO_TASMOTA_SPEED_MAP: - raise ValueError(f"Unsupported speed {speed}") - if speed == fan.SPEED_OFF: + if percentage == 0: await self.async_turn_off() else: - self._tasmota_entity.set_speed(HA_TO_TASMOTA_SPEED_MAP[speed]) + tasmota_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + self._tasmota_entity.set_speed(tasmota_speed) - async def async_turn_on(self, speed=None, **kwargs): + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): """Turn the fan on.""" # Tasmota does not support turning a fan on with implicit speed - await self.async_set_speed(speed or fan.SPEED_MEDIUM) + await self.async_set_percentage( + percentage + or ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, tasmota_const.FAN_SPEED_MEDIUM + ) + ) async def async_turn_off(self, **kwargs): """Turn the fan off.""" diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index bd48cae8e59..17e72a57ce6 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.7"], + "requirements": ["hatasmota==0.2.9"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/tasmota/translations/ko.json b/homeassistant/components/tasmota/translations/ko.json new file mode 100644 index 00000000000..c6e52d209e7 --- /dev/null +++ b/homeassistant/components/tasmota/translations/ko.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py index 868cd9b8557..9b7e1539fb4 100644 --- a/homeassistant/components/tcp/sensor.py +++ b/homeassistant/components/tcp/sensor.py @@ -78,10 +78,7 @@ class TcpSensor(Entity): @property def name(self): """Return the name of this sensor.""" - name = self._config[CONF_NAME] - if name is not None: - return name - return super().name + return self._config[CONF_NAME] @property def state(self): diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index ae98a5d8504..5d4721e60e6 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -12,7 +12,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from . import config_flow # noqa: F401 from .const import ( CONF_HOST, DOMAIN, diff --git a/homeassistant/components/tellduslive/translations/en.json b/homeassistant/components/tellduslive/translations/en.json index 7b14df15fa8..b1b9cd9ab10 100644 --- a/homeassistant/components/tellduslive/translations/en.json +++ b/homeassistant/components/tellduslive/translations/en.json @@ -5,7 +5,7 @@ "authorize_url_fail": "Unknown error generating an authorize url.", "authorize_url_timeout": "Timeout generating authorize URL.", "unknown": "Unexpected error", - "unknown_authorize_url_generation": "Unknown error generating an authorize url." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL." }, "error": { "invalid_auth": "Invalid authentication" diff --git a/homeassistant/components/tellduslive/translations/ko.json b/homeassistant/components/tellduslive/translations/ko.json index 645b7233bd7..d29dd504844 100644 --- a/homeassistant/components/tellduslive/translations/ko.json +++ b/homeassistant/components/tellduslive/translations/ko.json @@ -1,10 +1,14 @@ { "config": { "abort": { - "already_configured": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "authorize_url_fail": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", + "unknown_authorize_url_generation": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "auth": { diff --git a/homeassistant/components/tellduslive/translations/nl.json b/homeassistant/components/tellduslive/translations/nl.json index b3874dac77e..4eb6d40a142 100644 --- a/homeassistant/components/tellduslive/translations/nl.json +++ b/homeassistant/components/tellduslive/translations/nl.json @@ -4,7 +4,11 @@ "already_configured": "Service is al geconfigureerd", "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.", "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", - "unknown": "Onbekende fout opgetreden" + "unknown": "Onbekende fout opgetreden", + "unknown_authorize_url_generation": "Onbekende fout bij het genereren van een autorisatie-URL." + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" }, "step": { "auth": { diff --git a/homeassistant/components/tellduslive/translations/no.json b/homeassistant/components/tellduslive/translations/no.json index 649de0f86e4..563359d266a 100644 --- a/homeassistant/components/tellduslive/translations/no.json +++ b/homeassistant/components/tellduslive/translations/no.json @@ -2,10 +2,10 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "authorize_url_fail": "Ukjent feil ved generering av godkjenningsadresse", + "authorize_url_fail": "Ukjent feil under generering av en autoriserings-URL.", "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "unknown": "Uventet feil", - "unknown_authorize_url_generation": "Ukjent feil ved generering av godkjenningsadresse" + "unknown_authorize_url_generation": "Ukjent feil under generering av en autoriserings-URL." }, "error": { "invalid_auth": "Ugyldig godkjenning" diff --git a/homeassistant/components/tellduslive/translations/ru.json b/homeassistant/components/tellduslive/translations/ru.json index 0fc0c2f449f..95a16fa205f 100644 --- a/homeassistant/components/tellduslive/translations/ru.json +++ b/homeassistant/components/tellduslive/translations/ru.json @@ -8,7 +8,7 @@ "unknown_authorize_url_generation": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \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." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "auth": { diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index fd26b1702dc..c47aa1878fc 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -5,13 +5,17 @@ from temperusb.temper import TemperHandler import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_NAME, + CONF_OFFSET, + DEVICE_DEFAULT_NAME, + TEMP_FAHRENHEIT, +) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) CONF_SCALE = "scale" -CONF_OFFSET = "offset" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -26,7 +30,6 @@ TEMPER_SENSORS = [] def get_temper_devices(): """Scan the Temper devices from temperusb.""" - return TemperHandler().get_devices() diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 079569a9324..cc8862afcf4 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -7,13 +7,11 @@ from .const import DOMAIN, EVENT_TEMPLATE_RELOADED, PLATFORMS async def async_setup_reload_service(hass): """Create the reload service for the template domain.""" - if hass.services.has_service(DOMAIN, SERVICE_RELOAD): return async def _reload_config(call): """Reload the template platform config.""" - await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) hass.bus.async_fire(EVENT_TEMPLATE_RELOADED, context=call.context) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index ccefab767be..f56c5b27572 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -81,7 +81,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def _async_create_entities(hass, config): """Create Template Alarm Control Panels.""" - alarm_control_panels = [] for device, device_config in config[CONF_ALARM_CONTROL_PANELS].items(): @@ -114,7 +113,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Template Alarm Control Panels.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index f996b91a61e..b810c7faee1 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -97,7 +97,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template binary sensors.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -141,7 +140,6 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity): async def async_added_to_hass(self): """Register callbacks.""" - self.add_template_attribute("_state", self._template, None, self._update_state) if self._delay_on_raw is not None: diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index cf1ec8bc1c3..5b38f19eaeb 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -18,4 +18,5 @@ PLATFORMS = [ "sensor", "switch", "vacuum", + "weather", ] diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 93ffd2fd988..278cd1c80bb 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -20,6 +20,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.const import ( + CONF_COVERS, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_ENTITY_PICTURE_TEMPLATE, @@ -53,8 +54,6 @@ _VALID_STATES = [ "false", ] -CONF_COVERS = "covers" - CONF_POSITION_TEMPLATE = "position_template" CONF_TILT_TEMPLATE = "tilt_template" OPEN_ACTION = "open_cover" @@ -161,7 +160,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Template cover.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -228,7 +226,6 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_added_to_hass(self): """Register callbacks.""" - if self._template: self.add_template_attribute( "_position", self._template, None, self._update_state diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index fa67f60dac0..18a7d8262e0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -6,6 +6,8 @@ import voluptuous as vol from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, @@ -13,10 +15,12 @@ from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity, + preset_modes_from_speed_list, ) from homeassistant.const import ( CONF_ENTITY_ID, @@ -42,14 +46,20 @@ _LOGGER = logging.getLogger(__name__) CONF_FANS = "fans" CONF_SPEED_LIST = "speeds" +CONF_SPEED_COUNT = "speed_count" +CONF_PRESET_MODES = "preset_modes" CONF_SPEED_TEMPLATE = "speed_template" +CONF_PERCENTAGE_TEMPLATE = "percentage_template" +CONF_PRESET_MODE_TEMPLATE = "preset_mode_template" CONF_OSCILLATING_TEMPLATE = "oscillating_template" CONF_DIRECTION_TEMPLATE = "direction_template" CONF_ON_ACTION = "turn_on" CONF_OFF_ACTION = "turn_off" +CONF_SET_PERCENTAGE_ACTION = "set_percentage" CONF_SET_SPEED_ACTION = "set_speed" CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" +CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" _VALID_STATES = [STATE_ON, STATE_OFF] _VALID_OSC = [True, False] @@ -57,22 +67,32 @@ _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), + cv.deprecated(CONF_SPEED_LIST), + cv.deprecated(CONF_SPEED_TEMPLATE), + cv.deprecated(CONF_SET_SPEED_ACTION), vol.Schema( { vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), vol.Optional( - CONF_SPEED_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + CONF_SPEED_LIST, + default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], ): cv.ensure_list, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -93,6 +113,8 @@ async def _async_create_entities(hass, config): state_template = device_config[CONF_VALUE_TEMPLATE] speed_template = device_config.get(CONF_SPEED_TEMPLATE) + percentage_template = device_config.get(CONF_PERCENTAGE_TEMPLATE) + preset_mode_template = device_config.get(CONF_PRESET_MODE_TEMPLATE) oscillating_template = device_config.get(CONF_OSCILLATING_TEMPLATE) direction_template = device_config.get(CONF_DIRECTION_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) @@ -100,10 +122,14 @@ async def _async_create_entities(hass, config): on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] set_speed_action = device_config.get(CONF_SET_SPEED_ACTION) + set_percentage_action = device_config.get(CONF_SET_PERCENTAGE_ACTION) + set_preset_mode_action = device_config.get(CONF_SET_PRESET_MODE_ACTION) set_oscillating_action = device_config.get(CONF_SET_OSCILLATING_ACTION) set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] + speed_count = device_config.get(CONF_SPEED_COUNT) + preset_modes = device_config.get(CONF_PRESET_MODES) unique_id = device_config.get(CONF_UNIQUE_ID) fans.append( @@ -113,15 +139,21 @@ async def _async_create_entities(hass, config): friendly_name, state_template, speed_template, + percentage_template, + preset_mode_template, oscillating_template, direction_template, availability_template, on_action, off_action, set_speed_action, + set_percentage_action, + set_preset_mode_action, set_oscillating_action, set_direction_action, + speed_count, speed_list, + preset_modes, unique_id, ) ) @@ -131,7 +163,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template fans.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -146,15 +177,21 @@ class TemplateFan(TemplateEntity, FanEntity): friendly_name, state_template, speed_template, + percentage_template, + preset_mode_template, oscillating_template, direction_template, availability_template, on_action, off_action, set_speed_action, + set_percentage_action, + set_preset_mode_action, set_oscillating_action, set_direction_action, + speed_count, speed_list, + preset_modes, unique_id, ): """Initialize the fan.""" @@ -167,6 +204,8 @@ class TemplateFan(TemplateEntity, FanEntity): self._template = state_template self._speed_template = speed_template + self._percentage_template = percentage_template + self._preset_mode_template = preset_mode_template self._oscillating_template = oscillating_template self._direction_template = direction_template self._supported_features = 0 @@ -182,6 +221,18 @@ class TemplateFan(TemplateEntity, FanEntity): 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 + ) + + 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 + ) + self._set_oscillating_script = None if set_oscillating_action: self._set_oscillating_script = Script( @@ -196,10 +247,16 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = STATE_OFF self._speed = None + self._percentage = None + self._preset_mode = None self._oscillating = None self._direction = None - if self._speed_template: + if ( + self._speed_template + or self._percentage_template + or self._preset_mode_template + ): self._supported_features |= SUPPORT_SET_SPEED if self._oscillating_template: self._supported_features |= SUPPORT_OSCILLATE @@ -208,9 +265,15 @@ class TemplateFan(TemplateEntity, FanEntity): self._unique_id = unique_id + # Number of valid speeds + self._speed_count = speed_count + # List of valid speeds self._speed_list = speed_list + # List of valid preset modes + self._preset_modes = preset_modes + @property def name(self): """Return the display name of this fan.""" @@ -226,11 +289,23 @@ class TemplateFan(TemplateEntity, FanEntity): """Flag supported features.""" return self._supported_features + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return self._speed_count or super().speed_count + @property def speed_list(self) -> list: """Get the list of available speeds.""" return self._speed_list + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + if self._preset_modes is not None: + return self._preset_modes + return preset_modes_from_speed_list(self._speed_list) + @property def is_on(self): """Return true if device is on.""" @@ -241,6 +316,16 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the current speed.""" return self._speed + @property + def preset_mode(self): + """Return the current preset mode.""" + return self._preset_mode + + @property + def percentage(self): + """Return the current speed percentage.""" + return self._percentage + @property def oscillating(self): """Return the oscillation state.""" @@ -251,13 +336,29 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the oscillation state.""" return self._direction - # pylint: disable=arguments-differ - async def async_turn_on(self, speed: str = None) -> None: + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" - await self._on_script.async_run({ATTR_SPEED: speed}, context=self._context) + await self._on_script.async_run( + { + ATTR_SPEED: speed, + ATTR_PERCENTAGE: percentage, + ATTR_PRESET_MODE: preset_mode, + }, + context=self._context, + ) self._state = STATE_ON - if speed is not None: + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + elif percentage is not None: + await self.async_set_percentage(percentage) + elif speed is not None: await self.async_set_speed(speed) # pylint: disable=arguments-differ @@ -268,17 +369,53 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - if self._set_speed_script is None: + if speed not in self.speed_list: + _LOGGER.error( + "Received invalid speed: %s. Expected: %s", speed, self.speed_list + ) return - if speed in self._speed_list: - self._speed = speed + self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON + self._speed = speed + self._preset_mode = None + self._percentage = self.speed_to_percentage(speed) + + if self._set_speed_script: await self._set_speed_script.async_run( - {ATTR_SPEED: speed}, context=self._context + {ATTR_SPEED: self._speed}, context=self._context ) - else: + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage speed of the fan.""" + speed_list = self.speed_list + self._state = STATE_OFF if percentage == 0 else STATE_ON + self._speed = self.percentage_to_speed(percentage) if speed_list else None + self._percentage = percentage + self._preset_mode = None + + if self._set_percentage_script: + await self._set_percentage_script.async_run( + {ATTR_PERCENTAGE: self._percentage}, context=self._context + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset_mode of the fan.""" + if preset_mode not in self.preset_modes: _LOGGER.error( - "Received invalid speed: %s. Expected: %s", speed, self._speed_list + "Received invalid preset_mode: %s. Expected: %s", + preset_mode, + self.preset_modes, + ) + return + + self._state = STATE_ON + self._preset_mode = preset_mode + self._speed = preset_mode + self._percentage = None + + if self._set_preset_mode_script: + await self._set_preset_mode_script.async_run( + {ATTR_PRESET_MODE: self._preset_mode}, context=self._context ) async def async_oscillate(self, oscillating: bool) -> None: @@ -338,6 +475,22 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute("_state", self._template, None, self._update_state) + if self._preset_mode_template is not None: + self.add_template_attribute( + "_preset_mode", + self._preset_mode_template, + None, + self._update_preset_mode, + none_on_template_error=True, + ) + if self._percentage_template is not None: + self.add_template_attribute( + "_percentage", + self._percentage_template, + None, + self._update_percentage, + none_on_template_error=True, + ) if self._speed_template is not None: self.add_template_attribute( "_speed", @@ -370,14 +523,69 @@ class TemplateFan(TemplateEntity, FanEntity): speed = str(speed) if speed in self._speed_list: + self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON self._speed = speed + self._percentage = self.speed_to_percentage(speed) + self._preset_mode = speed if speed in self.preset_modes else None elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._speed = None + self._percentage = 0 + self._preset_mode = None else: _LOGGER.error( "Received invalid speed: %s. Expected: %s", speed, self._speed_list ) self._speed = None + self._percentage = 0 + self._preset_mode = None + + @callback + def _update_percentage(self, percentage): + # Validate percentage + try: + percentage = int(float(percentage)) + except ValueError: + _LOGGER.error("Received invalid percentage: %s", percentage) + self._speed = None + self._percentage = 0 + self._preset_mode = None + return + + if 0 <= percentage <= 100: + self._state = STATE_OFF if percentage == 0 else STATE_ON + self._percentage = percentage + if self._speed_list: + self._speed = self.percentage_to_speed(percentage) + self._preset_mode = None + else: + _LOGGER.error("Received invalid percentage: %s", percentage) + self._speed = None + self._percentage = 0 + self._preset_mode = None + + @callback + def _update_preset_mode(self, preset_mode): + # Validate preset mode + preset_mode = str(preset_mode) + + if preset_mode in self.preset_modes: + self._state = STATE_ON + self._speed = preset_mode + self._percentage = None + self._preset_mode = preset_mode + elif preset_mode in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + self._speed = None + self._percentage = None + self._preset_mode = None + else: + _LOGGER.error( + "Received invalid preset_mode: %s. Expected: %s", + preset_mode, + self.preset_mode, + ) + self._speed = None + self._percentage = None + self._preset_mode = None @callback def _update_oscillating(self, oscillating): diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 42493136b48..0edaacbb5ca 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -137,7 +137,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template lights.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -259,7 +258,6 @@ class LightTemplate(TemplateEntity, LightEntity): async def async_added_to_hass(self): """Register callbacks.""" - if self._template: self.add_template_attribute( "_state", self._template, None, self._update_state @@ -404,7 +402,6 @@ class LightTemplate(TemplateEntity, LightEntity): @callback def _update_state(self, result): """Update the state from the template.""" - if isinstance(result, TemplateError): # This behavior is legacy self._state = False @@ -431,7 +428,6 @@ class LightTemplate(TemplateEntity, LightEntity): @callback def _update_temperature(self, render): """Update the temperature from the template.""" - try: if render in ("None", ""): self._temperature = None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 2cd8bc00266..692f06e28fe 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -60,7 +60,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template lock.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -129,7 +128,6 @@ class TemplateLock(TemplateEntity, LockEntity): async def async_added_to_hass(self): """Register callbacks.""" - self.add_template_attribute( "_state", self._state_template, None, self._update_state ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index aea14884812..c67e3a275a3 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -59,7 +59,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def _async_create_entities(hass, config): """Create the template sensors.""" - sensors = [] for device, device_config in config[CONF_SENSORS].items(): @@ -96,7 +95,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template sensors.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -140,7 +138,6 @@ class SensorTemplate(TemplateEntity, Entity): async def async_added_to_hass(self): """Register callbacks.""" - self.add_template_attribute("_state", self._template, None, self._update_state) if self._friendly_name_template is not None: self.add_template_attribute("_name", self._friendly_name_template) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 511338b5aa1..412c4507d1f 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -90,7 +90,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template switches.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -147,7 +146,6 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_added_to_hass(self): """Register callbacks.""" - if self._template is None: # restore state after startup diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 49b0edfab02..f350eb87d61 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -223,7 +223,6 @@ class TemplateEntity(Entity): updates: List[TrackTemplateResult], ) -> None: """Call back the results to the attributes.""" - if event: self.async_set_context(event.context) diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 80ad585486b..1f378c59335 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -37,21 +37,51 @@ async def async_attach_trigger( template.attach(hass, time_delta) delay_cancel = None job = HassJob(action) + armed = False + + # Arm at setup if the template is already false. + try: + if not result_as_boolean( + value_template.async_render(automation_info["variables"]) + ): + armed = True + except exceptions.TemplateError as ex: + _LOGGER.warning( + "Error initializing 'template' trigger for '%s': %s", + automation_info["name"], + ex, + ) @callback def template_listener(event, updates): """Listen for state changes and calls action.""" - nonlocal delay_cancel + nonlocal delay_cancel, armed result = updates.pop().result + if isinstance(result, exceptions.TemplateError): + _LOGGER.warning( + "Error evaluating 'template' trigger for '%s': %s", + automation_info["name"], + result, + ) + return + if delay_cancel: # pylint: disable=not-callable delay_cancel() delay_cancel = None if not result_as_boolean(result): + armed = True return + # Only fire when previously armed. + if not armed: + return + + # Fire! + armed = False + entity_id = event and event.data.get("entity_id") from_s = event and event.data.get("old_state") to_s = event and event.data.get("new_state") diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 5bf8148b96e..171aeb7af92 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -147,7 +147,6 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template vacuums.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities(await _async_create_entities(hass, config)) @@ -337,7 +336,6 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_added_to_hass(self): """Register callbacks.""" - if self._template is not None: self.add_template_attribute( "_state", self._template, None, self._update_state diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py new file mode 100644 index 00000000000..560bd5639ba --- /dev/null +++ b/homeassistant/components/template/weather.py @@ -0,0 +1,220 @@ +"""Template platform that aggregates meteorological data.""" +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + ENTITY_ID_FORMAT, + WeatherEntity, +) +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.reload import async_setup_reload_service + +from .const import DOMAIN, PLATFORMS +from .template_entity import TemplateEntity + +CONDITION_CLASSES = { + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + ATTR_CONDITION_EXCEPTIONAL, +} + +CONF_WEATHER = "weather" +CONF_TEMPERATURE_TEMPLATE = "temperature_template" +CONF_HUMIDITY_TEMPLATE = "humidity_template" +CONF_CONDITION_TEMPLATE = "condition_template" +CONF_PRESSURE_TEMPLATE = "pressure_template" +CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" +CONF_FORECAST_TEMPLATE = "forecast_template" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Template weather.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + name = config[CONF_NAME] + condition_template = config[CONF_CONDITION_TEMPLATE] + temperature_template = config[CONF_TEMPERATURE_TEMPLATE] + humidity_template = config[CONF_HUMIDITY_TEMPLATE] + pressure_template = config.get(CONF_PRESSURE_TEMPLATE) + wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) + forecast_template = config.get(CONF_FORECAST_TEMPLATE) + unique_id = config.get(CONF_UNIQUE_ID) + + async_add_entities( + [ + WeatherTemplate( + hass, + name, + condition_template, + temperature_template, + humidity_template, + pressure_template, + wind_speed_template, + forecast_template, + unique_id, + ) + ] + ) + + +class WeatherTemplate(TemplateEntity, WeatherEntity): + """Representation of a weather condition.""" + + def __init__( + self, + hass, + name, + condition_template, + temperature_template, + humidity_template, + pressure_template, + wind_speed_template, + forecast_template, + unique_id, + ): + """Initialize the Demo weather.""" + super().__init__() + + self._name = name + self._condition_template = condition_template + self._temperature_template = temperature_template + self._humidity_template = humidity_template + self._pressure_template = pressure_template + self._wind_speed_template = wind_speed_template + self._forecast_template = forecast_template + self._unique_id = unique_id + + self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) + + self._condition = None + self._temperature = None + self._humidity = None + self._pressure = None + self._wind_speed = None + self._forecast = [] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def condition(self): + """Return the current condition.""" + return self._condition + + @property + def temperature(self): + """Return the temperature.""" + return self._temperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + @property + def humidity(self): + """Return the humidity.""" + return self._humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._wind_speed + + @property + def pressure(self): + """Return the pressure.""" + return self._pressure + + @property + def forecast(self): + """Return the forecast.""" + return self._forecast + + @property + def attribution(self): + """Return the attribution.""" + return "Powered by Home Assistant" + + @property + def unique_id(self): + """Return the unique id of this light.""" + return self._unique_id + + async def async_added_to_hass(self): + """Register callbacks.""" + + if self._condition_template: + self.add_template_attribute( + "_condition", + self._condition_template, + lambda condition: condition if condition in CONDITION_CLASSES else None, + ) + if self._temperature_template: + self.add_template_attribute( + "_temperature", + self._temperature_template, + ) + if self._humidity_template: + self.add_template_attribute( + "_humidity", + self._humidity_template, + ) + if self._pressure_template: + self.add_template_attribute( + "_pressure", + self._pressure_template, + ) + if self._wind_speed_template: + self.add_template_attribute( + "_wind_speed", + self._wind_speed_template, + ) + if self._forecast_template: + self.add_template_attribute( + "_forecast", + self._forecast_template, + ) + await super().async_added_to_hass() diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index f039a14d5b3..300c3ddd1db 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -7,7 +7,7 @@ "tf-models-official==2.3.0", "pycocotools==2.0.1", "numpy==1.19.2", - "pillow==8.1.0" + "pillow==8.1.1" ], "codeowners": [] } diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index 194ea71a3b7..8dce5e238ac 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -106,6 +106,7 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(): if entry.data.get(CONF_USERNAME) == username: return entry + return None class OptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/tesla/translations/ca.json b/homeassistant/components/tesla/translations/ca.json index 4d0583af408..2a51c0297ae 100644 --- a/homeassistant/components/tesla/translations/ca.json +++ b/homeassistant/components/tesla/translations/ca.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, "error": { "already_configured": "El compte ja ha estat configurat", "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/tesla/translations/cs.json b/homeassistant/components/tesla/translations/cs.json index c611b85d734..9c117223d40 100644 --- a/homeassistant/components/tesla/translations/cs.json +++ b/homeassistant/components/tesla/translations/cs.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, "error": { "already_configured": "\u00da\u010det je ji\u017e nastaven", "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json index 558209af411..2fd964fe013 100644 --- a/homeassistant/components/tesla/translations/de.json +++ b/homeassistant/components/tesla/translations/de.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, "error": { "already_configured": "Konto wurde bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/tesla/translations/et.json b/homeassistant/components/tesla/translations/et.json index ae427f5d1e7..c7ceae36990 100644 --- a/homeassistant/components/tesla/translations/et.json +++ b/homeassistant/components/tesla/translations/et.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, "error": { "already_configured": "Konto on juba h\u00e4\u00e4lestatud", "cannot_connect": "\u00dchendamine nurjus", diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json index 6134ff25f6b..889c32a7d91 100644 --- a/homeassistant/components/tesla/translations/fr.json +++ b/homeassistant/components/tesla/translations/fr.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, "error": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/tesla/translations/it.json b/homeassistant/components/tesla/translations/it.json index a316b41c29c..3a137da78f1 100644 --- a/homeassistant/components/tesla/translations/it.json +++ b/homeassistant/components/tesla/translations/it.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, "error": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/tesla/translations/ko.json b/homeassistant/components/tesla/translations/ko.json index 27a96518ca7..3e3893e0b75 100644 --- a/homeassistant/components/tesla/translations/ko.json +++ b/homeassistant/components/tesla/translations/ko.json @@ -1,5 +1,14 @@ { "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/tesla/translations/nl.json b/homeassistant/components/tesla/translations/nl.json index 9e79b35165d..f6289de6d9d 100644 --- a/homeassistant/components/tesla/translations/nl.json +++ b/homeassistant/components/tesla/translations/nl.json @@ -1,8 +1,13 @@ { "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, "error": { "already_configured": "Account is al geconfigureerd", - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" }, "step": { "user": { diff --git a/homeassistant/components/tesla/translations/no.json b/homeassistant/components/tesla/translations/no.json index 36cceb97f9f..ce706640636 100644 --- a/homeassistant/components/tesla/translations/no.json +++ b/homeassistant/components/tesla/translations/no.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, "error": { "already_configured": "Kontoen er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/tesla/translations/pl.json b/homeassistant/components/tesla/translations/pl.json index dc4144d0f6a..7ec634cd56c 100644 --- a/homeassistant/components/tesla/translations/pl.json +++ b/homeassistant/components/tesla/translations/pl.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, "error": { "already_configured": "Konto jest ju\u017c skonfigurowane", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/tesla/translations/ru.json b/homeassistant/components/tesla/translations/ru.json index 8fe167d8631..d62a2e1f168 100644 --- a/homeassistant/components/tesla/translations/ru.json +++ b/homeassistant/components/tesla/translations/ru.json @@ -1,9 +1,13 @@ { "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": { "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.", "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/tesla/translations/zh-Hant.json b/homeassistant/components/tesla/translations/zh-Hant.json index 235c9036637..d9b7fd4ef79 100644 --- a/homeassistant/components/tesla/translations/zh-Hant.json +++ b/homeassistant/components/tesla/translations/zh-Hant.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, "error": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "cannot_connect": "\u9023\u7dda\u5931\u6557", diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index eab843069f4..2e7b7f9499b 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -8,7 +8,14 @@ import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_TIME, + CONF_DEVICE_ID, + CONTENT_TYPE_JSON, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -17,12 +24,9 @@ from . import DATA_TTN, TTN_ACCESS_KEY, TTN_APP_ID, TTN_DATA_STORAGE_URL _LOGGER = logging.getLogger(__name__) -ATTR_DEVICE_ID = "device_id" ATTR_RAW = "raw" -ATTR_TIME = "time" DEFAULT_TIMEOUT = 10 -CONF_DEVICE_ID = "device_id" CONF_VALUES = "values" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/tibber/translations/ko.json b/homeassistant/components/tibber/translations/ko.json index 1f99aa440b4..5ba1f62e4ed 100644 --- a/homeassistant/components/tibber/translations/ko.json +++ b/homeassistant/components/tibber/translations/ko.json @@ -1,9 +1,10 @@ { "config": { "abort": { - "already_configured": "Tibber \uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "timeout": "Tibber \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4." }, diff --git a/homeassistant/components/tibber/translations/nl.json b/homeassistant/components/tibber/translations/nl.json index 4a89639cf50..4a5e518f306 100644 --- a/homeassistant/components/tibber/translations/nl.json +++ b/homeassistant/components/tibber/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Service is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_access_token": "Ongeldig toegangstoken", "timeout": "Time-out om verbinding te maken met Tibber" }, diff --git a/homeassistant/components/tibber/translations/zh-Hant.json b/homeassistant/components/tibber/translations/zh-Hant.json index ce10615a289..e4d0ec10e23 100644 --- a/homeassistant/components/tibber/translations/zh-Hant.json +++ b/homeassistant/components/tibber/translations/zh-Hant.json @@ -5,15 +5,15 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_access_token": "\u5b58\u53d6\u5bc6\u9470\u7121\u6548", + "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "timeout": "\u9023\u7dda\u81f3 Tibber \u903e\u6642" }, "step": { "user": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470" + "access_token": "\u5b58\u53d6\u6b0a\u6756" }, - "description": "\u8f38\u5165\u7531 https://developer.tibber.com/settings/accesstoken \u6240\u7372\u5f97\u7684\u5b58\u53d6\u5bc6\u9470", + "description": "\u8f38\u5165\u7531 https://developer.tibber.com/settings/accesstoken \u6240\u7372\u5f97\u7684\u5b58\u53d6\u6b0a\u6756", "title": "Tibber" } } diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index aceed9aa7ee..205742017d3 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta from functools import partial from pytile import async_login -from pytile.errors import SessionExpiredError, TileError +from pytile.errors import InvalidAuthError, SessionExpiredError, TileError from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.exceptions import ConfigEntryNotReady @@ -43,6 +43,9 @@ async def async_setup_entry(hass, entry): session=websession, ) hass.data[DOMAIN][DATA_TILE][entry.entry_id] = await client.async_get_tiles() + except InvalidAuthError: + LOGGER.error("Invalid credentials provided") + return False except TileError as err: raise ConfigEntryNotReady("Error during integration setup") from err diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index ae3852a2b07..f7cc4e1736e 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -16,9 +16,10 @@ ATTR_ALTITUDE = "altitude" ATTR_CONNECTION_STATE = "connection_state" ATTR_IS_DEAD = "is_dead" ATTR_IS_LOST = "is_lost" +ATTR_LAST_LOST_TIMESTAMP = "last_lost_timestamp" ATTR_RING_STATE = "ring_state" -ATTR_VOIP_STATE = "voip_state" ATTR_TILE_NAME = "tile_name" +ATTR_VOIP_STATE = "voip_state" DEFAULT_ATTRIBUTION = "Data provided by Tile" DEFAULT_ICON = "mdi:view-grid" @@ -135,6 +136,7 @@ class TileDeviceTracker(CoordinatorEntity, TrackerEntity): { ATTR_ALTITUDE: self._tile.altitude, ATTR_IS_LOST: self._tile.lost, + ATTR_LAST_LOST_TIMESTAMP: self._tile.lost_timestamp, ATTR_RING_STATE: self._tile.ring_state, ATTR_VOIP_STATE: self._tile.voip_state, } diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index 854fc663ba2..194fc49418a 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -3,6 +3,6 @@ "name": "Tile", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tile", - "requirements": ["pytile==5.1.0"], + "requirements": ["pytile==5.2.0"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/tile/translations/ko.json b/homeassistant/components/tile/translations/ko.json index 50ba5000a1a..d592fef112c 100644 --- a/homeassistant/components/tile/translations/ko.json +++ b/homeassistant/components/tile/translations/ko.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "\uc774 Tile \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/tile/translations/nl.json b/homeassistant/components/tile/translations/nl.json index 26c57268689..236d250122a 100644 --- a/homeassistant/components/tile/translations/nl.json +++ b/homeassistant/components/tile/translations/nl.json @@ -3,10 +3,14 @@ "abort": { "already_configured": "Apparaat is al geconfigureerd" }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, "step": { "user": { "data": { - "password": "Wachtwoord" + "password": "Wachtwoord", + "username": "E-mail" }, "title": "Tegel configureren" } diff --git a/homeassistant/components/tile/translations/ru.json b/homeassistant/components/tile/translations/ru.json index 62d0b10857c..f42a4d631b0 100644 --- a/homeassistant/components/tile/translations/ru.json +++ b/homeassistant/components/tile/translations/ru.json @@ -4,7 +4,7 @@ "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 64d651b4cd8..b0ff60bbcae 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -1,4 +1,6 @@ """Support for Timers.""" +from __future__ import annotations + from datetime import datetime, timedelta import logging from typing import Dict, Optional @@ -107,8 +109,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: yaml_collection = collection.YamlCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, Timer.from_yaml + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, Timer.from_yaml ) storage_collection = TimerStorageCollection( @@ -116,7 +118,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection(component, storage_collection, Timer) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, Timer + ) await yaml_collection.async_load( [{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()] @@ -127,9 +131,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) - collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) - async def reload_service_handler(service_call: ServiceCallType) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -199,7 +200,7 @@ class Timer(RestoreEntity): self._listener = None @classmethod - def from_yaml(cls, config: Dict) -> "Timer": + def from_yaml(cls, config: Dict) -> Timer: """Return entity instance initialized from yaml storage.""" timer = cls(config) timer.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index cd810c21de5..54175de3cf7 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -1,36 +1,28 @@ # Describes the format for available timer services start: - description: Start a timer. - + name: Start + description: Start a timer + target: fields: - entity_id: - description: Entity id of the timer to start. [optional] - example: "timer.timer0" duration: description: Duration the timer requires to finish. [optional] + default: 0 example: "00:01:00 or 60" + selector: + text: pause: + name: Pause description: Pause a timer. - - fields: - entity_id: - description: Entity id of the timer to pause. [optional] - example: "timer.timer0" + target: cancel: + name: Cancel description: Cancel a timer. - - fields: - entity_id: - description: Entity id of the timer to cancel. [optional] - example: "timer.timer0" + target: finish: + name: Finish description: Finish a timer. - - fields: - entity_id: - description: Entity id of the timer to finish. [optional] - example: "timer.timer0" + target: diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 978e58c2500..1188831c26d 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -246,7 +246,7 @@ class TodoistProjectDevice(CalendarEventDevice): data, labels, token, - latest_task_due_date=None, + due_date_days=None, whitelisted_labels=None, whitelisted_projects=None, ): @@ -255,7 +255,7 @@ class TodoistProjectDevice(CalendarEventDevice): data, labels, token, - latest_task_due_date, + due_date_days, whitelisted_labels, whitelisted_projects, ) @@ -338,7 +338,7 @@ class TodoistProjectData: project_data, labels, api, - latest_task_due_date=None, + due_date_days=None, whitelisted_labels=None, whitelisted_projects=None, ): @@ -356,12 +356,12 @@ class TodoistProjectData: self.all_project_tasks = [] - # The latest date a task can be due (for making lists of everything + # The days a task can be due (for making lists of everything # due today, or everything due in the next week, for example). - if latest_task_due_date is not None: - self._latest_due_date = dt.utcnow() + timedelta(days=latest_task_due_date) + if due_date_days is not None: + self._due_date_days = timedelta(days=due_date_days) else: - self._latest_due_date = None + self._due_date_days = None # Only tasks with one of these labels will be included. if whitelisted_labels is not None: @@ -409,8 +409,8 @@ class TodoistProjectData: if data[DUE] is not None: task[END] = _parse_due_date(data[DUE]) - if self._latest_due_date is not None and ( - task[END] > self._latest_due_date + if self._due_date_days is not None and ( + task[END] > dt.utcnow() + self._due_date_days ): # This task is out of range of our due date; # it shouldn't be counted. @@ -430,7 +430,7 @@ class TodoistProjectData: else: # If we ask for everything due before a certain date, don't count # things which have no due dates. - if self._latest_due_date is not None: + if self._due_date_days is not None: return None # Define values for tasks without due dates diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py index d1de68ef0b8..1e1739e85df 100644 --- a/homeassistant/components/toon/config_flow.py +++ b/homeassistant/components/toon/config_flow.py @@ -56,7 +56,6 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """ if config is not None and CONF_MIGRATE in config: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({CONF_MIGRATE: config[CONF_MIGRATE]}) else: await self._async_handle_discovery_without_unique_id() @@ -87,10 +86,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): return await self._create_entry(self.agreements[agreement_index]) async def _create_entry(self, agreement: Agreement) -> Dict[str, Any]: - if ( # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - CONF_MIGRATE in self.context - ): - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + if CONF_MIGRATE in self.context: await self.hass.config_entries.async_remove(self.context[CONF_MIGRATE]) await self.async_set_unique_id(agreement.agreement_id) diff --git a/homeassistant/components/toon/services.yaml b/homeassistant/components/toon/services.yaml index 7afedeb4bf6..909018f820b 100644 --- a/homeassistant/components/toon/services.yaml +++ b/homeassistant/components/toon/services.yaml @@ -1,6 +1,11 @@ update: + name: Update description: Update all entities with fresh data from Toon fields: display: + name: Display description: Toon display to update (optional) + advanced: true example: eneco-001-123456 + selector: + text: diff --git a/homeassistant/components/toon/translations/en.json b/homeassistant/components/toon/translations/en.json index c64913cfb6c..3351c16d8d8 100644 --- a/homeassistant/components/toon/translations/en.json +++ b/homeassistant/components/toon/translations/en.json @@ -7,7 +7,7 @@ "missing_configuration": "The component is not configured. Please follow the documentation.", "no_agreements": "This account has no Toon displays.", "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "unknown_authorize_url_generation": "Unknown error generating an authorize url." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL." }, "step": { "agreement": { diff --git a/homeassistant/components/toon/translations/et.json b/homeassistant/components/toon/translations/et.json index 7b70eae433e..f93fb684b25 100644 --- a/homeassistant/components/toon/translations/et.json +++ b/homeassistant/components/toon/translations/et.json @@ -18,7 +18,7 @@ "title": "Vali oma leping" }, "pick_implementation": { - "title": "Valige oma rentnik, kellega autentida" + "title": "Vali oma rentnik, kellega autentida" } } } diff --git a/homeassistant/components/toon/translations/ko.json b/homeassistant/components/toon/translations/ko.json index 379058f68d1..faed1fe74d7 100644 --- a/homeassistant/components/toon/translations/ko.json +++ b/homeassistant/components/toon/translations/ko.json @@ -2,11 +2,12 @@ "config": { "abort": { "already_configured": "\uc120\ud0dd\ub41c \uc57d\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", "no_agreements": "\uc774 \uacc4\uc815\uc5d0\ub294 Toon \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", + "unknown_authorize_url_generation": "\uc778\uc99d URL \uc744 \uc0dd\uc131\ud558\ub294 \ub3d9\uc548 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." }, "step": { "agreement": { diff --git a/homeassistant/components/toon/translations/nl.json b/homeassistant/components/toon/translations/nl.json index 4f63d7d09da..a0cda915172 100644 --- a/homeassistant/components/toon/translations/nl.json +++ b/homeassistant/components/toon/translations/nl.json @@ -3,8 +3,11 @@ "abort": { "already_configured": "De geselecteerde overeenkomst is al geconfigureerd.", "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie-URL.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen.", "no_agreements": "Dit account heeft geen Toon schermen.", - "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout [check the help section] ( {docs_url} )" + "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout [check the help section] ( {docs_url} )", + "unknown_authorize_url_generation": "Onbekende fout bij het genereren van een autorisatie-URL." } } } \ No newline at end of file diff --git a/homeassistant/components/toon/translations/no.json b/homeassistant/components/toon/translations/no.json index a64a64ab74e..41246c42f0e 100644 --- a/homeassistant/components/toon/translations/no.json +++ b/homeassistant/components/toon/translations/no.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "Den valgte avtalen er allerede konfigurert.", - "authorize_url_fail": "Ukjent feil ved generering av godkjenningsadresse", + "authorize_url_fail": "Ukjent feil under generering av en autoriserings-URL.", "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", "no_agreements": "Denne kontoen har ingen Toon skjermer.", "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", - "unknown_authorize_url_generation": "Ukjent feil ved generering av godkjenningsadresse" + "unknown_authorize_url_generation": "Ukjent feil under generering av en autoriserings-URL." }, "step": { "agreement": { diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index cf3f059cfb9..179d60b794a 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -5,60 +5,79 @@ import logging from total_connect_client import TotalConnectClient import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from .const import DOMAIN +from .const import CONF_USERCODES, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = ["alarm_control_panel", "binary_sensor"] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: dict): """Set up by configuration file.""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) + hass.data.setdefault(DOMAIN, {}) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up upon config entry in user interface.""" - hass.data.setdefault(DOMAIN, {}) - conf = entry.data username = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] + if CONF_USERCODES not in conf: + _LOGGER.warning("No usercodes in TotalConnect configuration") + # should only happen for those who used UI before we added usercodes + await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + }, + data=conf, + ) + return False + + temp_codes = conf[CONF_USERCODES] + usercodes = {} + for code in temp_codes: + usercodes[int(code)] = temp_codes[code] + client = await hass.async_add_executor_job( - TotalConnectClient.TotalConnectClient, username, password + TotalConnectClient.TotalConnectClient, username, password, usercodes ) if not client.is_valid_credentials(): _LOGGER.error("TotalConnect authentication failed") + await hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + }, + data=conf, + ) + ) + return False hass.data[DOMAIN][entry.entry_id] = client diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index d7c17a1ccff..affff382365 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -21,8 +21,6 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up TotalConnect alarm panels based on a config entry.""" diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index e296b12fa59..6bee603d1b1 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -1,6 +1,4 @@ """Interfaces with TotalConnect sensors.""" -import logging - from homeassistant.components.binary_sensor import ( DEVICE_CLASS_DOOR, DEVICE_CLASS_GAS, @@ -10,8 +8,6 @@ from homeassistant.components.binary_sensor import ( from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up TotalConnect device sensors based on a config entry.""" diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 2608a3c812c..27fa4203a42 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -5,7 +5,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN # pylint: disable=unused-import +from .const import CONF_USERCODES, DOMAIN # pylint: disable=unused-import + +CONF_LOCATION = "location" + +PASSWORD_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -13,6 +17,13 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self): + """Initialize the config flow.""" + self.username = None + self.password = None + self.usercodes = {} + self.client = None + async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" errors = {} @@ -25,14 +36,16 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(username) self._abort_if_unique_id_configured() - valid = await self.is_valid(username, password) + client = await self.hass.async_add_executor_job( + TotalConnectClient.TotalConnectClient, username, password, None + ) - if valid: - # authentication success / valid - return self.async_create_entry( - title="Total Connect", - data={CONF_USERNAME: username, CONF_PASSWORD: password}, - ) + if client.is_valid_credentials(): + # 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" @@ -44,13 +57,101 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_import(self, user_input): - """Import a config entry.""" - return await self.async_step_user(user_input) + async def async_step_locations(self, user_entry=None): + """Handle the user locations and associated usercodes.""" + errors = {} + if user_entry is not None: + for location_id in self.usercodes: + if self.usercodes[location_id] is None: + valid = await self.hass.async_add_executor_job( + self.client.locations[location_id].set_usercode, + user_entry[CONF_LOCATION], + ) + if valid: + self.usercodes[location_id] = user_entry[CONF_LOCATION] + else: + errors[CONF_LOCATION] = "usercode" + break - async def is_valid(self, username="", password=""): - """Return true if the given username and password are valid.""" - client = await self.hass.async_add_executor_job( - TotalConnectClient.TotalConnectClient, username, password + complete = True + for location_id in self.usercodes: + if self.usercodes[location_id] is None: + complete = False + + if not errors and complete: + return self.async_create_entry( + title="Total Connect", + data={ + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + CONF_USERCODES: self.usercodes, + }, + ) + else: + for location_id in self.client.locations: + self.usercodes[location_id] = None + + # show the next location that needs a usercode + location_codes = {} + for location_id in self.usercodes: + if self.usercodes[location_id] is None: + location_codes[ + vol.Required( + CONF_LOCATION, + default=location_id, + ) + ] = str + break + + data_schema = vol.Schema(location_codes) + return self.async_show_form( + step_id="locations", + data_schema=data_schema, + errors=errors, + description_placeholders={"base": "description"}, ) - return client.is_valid_credentials() + + async def async_step_reauth(self, config): + """Perform reauth upon an authentication error or no usercode.""" + self.username = config[CONF_USERNAME] + self.usercodes = config[CONF_USERCODES] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + errors = {} + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=PASSWORD_DATA_SCHEMA, + ) + + client = await self.hass.async_add_executor_job( + TotalConnectClient.TotalConnectClient, + self.username, + user_input[CONF_PASSWORD], + self.usercodes, + ) + + if not client.is_valid_credentials(): + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="reauth_confirm", + errors=errors, + data_schema=PASSWORD_DATA_SCHEMA, + ) + + existing_entry = await self.async_set_unique_id(self.username) + new_entry = { + CONF_USERNAME: self.username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERCODES: self.usercodes, + } + self.hass.config_entries.async_update_entry(existing_entry, data=new_entry) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/totalconnect/const.py b/homeassistant/components/totalconnect/const.py index 6c19bf0a217..22ecd14281f 100644 --- a/homeassistant/components/totalconnect/const.py +++ b/homeassistant/components/totalconnect/const.py @@ -1,3 +1,8 @@ """TotalConnect constants.""" DOMAIN = "totalconnect" +CONF_USERCODES = "usercodes" +CONF_LOCATION = "location" + +# Most TotalConnect alarms will work passing '-1' as usercode +DEFAULT_USERCODE = "-1" diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 4ec632f4577..8a42ca99f03 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -1,8 +1,9 @@ { "domain": "totalconnect", - "name": "Honeywell Total Connect Alarm", + "name": "Total Connect", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==0.55"], + "requirements": ["total_connect_client==0.57"], + "dependencies": [], "codeowners": ["@austinmroczek"], "config_flow": true } diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 7b306554b7b..f284e4b86da 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -7,13 +7,26 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "locations": { + "title": "Location Usercodes", + "description": "Enter the usercode for this user at this location", + "data": { + "location": "[%key:common::config_flow::data::location%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Total Connect needs to re-authenticate your account" } }, "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "usercode": "Usercode not valid for this user at this location" }, "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%]" } } } diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index 25dafcf7d21..ce055082a21 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -1,12 +1,25 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "usercode": "El codi d'usuari no \u00e9s v\u00e0lid per a aquest usuari en aquesta ubicaci\u00f3" }, "step": { + "locations": { + "data": { + "location": "Ubicaci\u00f3" + }, + "description": "Introdueix el codi d'usuari de l'usuari en aquesta ubicaci\u00f3", + "title": "Codis d'usuari d'ubicaci\u00f3" + }, + "reauth_confirm": { + "description": "Total Connect ha de tornar a autenticar-se amb el teu compte", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "password": "Contrasenya", diff --git a/homeassistant/components/totalconnect/translations/cs.json b/homeassistant/components/totalconnect/translations/cs.json index 60e2196b387..74dece0c54e 100644 --- a/homeassistant/components/totalconnect/translations/cs.json +++ b/homeassistant/components/totalconnect/translations/cs.json @@ -1,12 +1,18 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" }, "step": { + "locations": { + "data": { + "location": "Um\u00edst\u011bn\u00ed" + } + }, "user": { "data": { "password": "Heslo", diff --git a/homeassistant/components/totalconnect/translations/de.json b/homeassistant/components/totalconnect/translations/de.json index 530fef95af2..3fb5bb8f3e1 100644 --- a/homeassistant/components/totalconnect/translations/de.json +++ b/homeassistant/components/totalconnect/translations/de.json @@ -1,12 +1,21 @@ { "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" }, "step": { + "locations": { + "data": { + "location": "Standort" + } + }, + "reauth_confirm": { + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "password": "Passwort", diff --git a/homeassistant/components/totalconnect/translations/en.json b/homeassistant/components/totalconnect/translations/en.json index f02a3eadf9c..5071e623701 100644 --- a/homeassistant/components/totalconnect/translations/en.json +++ b/homeassistant/components/totalconnect/translations/en.json @@ -1,12 +1,25 @@ { "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" + "invalid_auth": "Invalid authentication", + "usercode": "Usercode not valid for this user at this location" }, "step": { + "locations": { + "data": { + "location": "Location" + }, + "description": "Enter the usercode for this user at this location", + "title": "Location Usercodes" + }, + "reauth_confirm": { + "description": "Total Connect needs to re-authenticate your account", + "title": "Reauthenticate Integration" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/totalconnect/translations/es.json b/homeassistant/components/totalconnect/translations/es.json index 48af1bed0f4..090d9271dee 100644 --- a/homeassistant/components/totalconnect/translations/es.json +++ b/homeassistant/components/totalconnect/translations/es.json @@ -4,9 +4,17 @@ "already_configured": "La cuenta ya ha sido configurada" }, "error": { - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "usercode": "El c\u00f3digo de usuario no es v\u00e1lido para este usuario en esta ubicaci\u00f3n" }, "step": { + "locations": { + "description": "Ingrese el c\u00f3digo de usuario para este usuario en esta ubicaci\u00f3n", + "title": "C\u00f3digos de usuario de ubicaci\u00f3n" + }, + "reauth_confirm": { + "description": "Total Connect necesita volver a autentificar tu cuenta" + }, "user": { "data": { "password": "Contrase\u00f1a", diff --git a/homeassistant/components/totalconnect/translations/et.json b/homeassistant/components/totalconnect/translations/et.json index 2940b7a9e65..3f1a15fe139 100644 --- a/homeassistant/components/totalconnect/translations/et.json +++ b/homeassistant/components/totalconnect/translations/et.json @@ -1,12 +1,25 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { - "invalid_auth": "Tuvastamise viga" + "invalid_auth": "Tuvastamise viga", + "usercode": "Kasutajakood ei sobi selle kasutaja jaoks selles asukohas" }, "step": { + "locations": { + "data": { + "location": "Asukoht" + }, + "description": "Sisesta selle kasutaja kood selles asukohas", + "title": "Asukoha kasutajakoodid" + }, + "reauth_confirm": { + "description": "Total Connect peab konto uuesti autentima", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "password": "Salas\u00f5na", diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index 6526d0a9800..b46bf127963 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -1,12 +1,25 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "invalid_auth": "Authentification invalide" + "invalid_auth": "Authentification invalide", + "usercode": "Code d'utilisateur non valide pour cet utilisateur \u00e0 cet emplacement" }, "step": { + "locations": { + "data": { + "location": "Emplacement" + }, + "description": "Saisissez le code d'utilisateur de cet utilisateur \u00e0 cet emplacement", + "title": "Codes d'utilisateur de l'emplacement" + }, + "reauth_confirm": { + "description": "Total Connect doit r\u00e9-authentifier votre compte", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "password": "Mot de passe", diff --git a/homeassistant/components/totalconnect/translations/it.json b/homeassistant/components/totalconnect/translations/it.json index 2a12d00f57d..18ecf648310 100644 --- a/homeassistant/components/totalconnect/translations/it.json +++ b/homeassistant/components/totalconnect/translations/it.json @@ -1,12 +1,25 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { - "invalid_auth": "Autenticazione non valida" + "invalid_auth": "Autenticazione non valida", + "usercode": "Codice utente non valido per questo utente in questa posizione" }, "step": { + "locations": { + "data": { + "location": "Posizione" + }, + "description": "Immettere il codice utente per questo utente in questa posizione", + "title": "Codici utente posizione" + }, + "reauth_confirm": { + "description": "Total Connect deve autenticare nuovamente il tuo account", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/totalconnect/translations/ko.json b/homeassistant/components/totalconnect/translations/ko.json index 99513a64508..c074472b8f4 100644 --- a/homeassistant/components/totalconnect/translations/ko.json +++ b/homeassistant/components/totalconnect/translations/ko.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/totalconnect/translations/nl.json b/homeassistant/components/totalconnect/translations/nl.json index c72b7e368ac..94d8e3ac01e 100644 --- a/homeassistant/components/totalconnect/translations/nl.json +++ b/homeassistant/components/totalconnect/translations/nl.json @@ -1,12 +1,21 @@ { "config": { "abort": { - "already_configured": "Account al geconfigureerd" + "already_configured": "Account al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "invalid_auth": "Ongeldige authenticatie" }, "step": { + "locations": { + "data": { + "location": "Locatie" + } + }, + "reauth_confirm": { + "title": "Verifieer de integratie opnieuw" + }, "user": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json index e80f86696fc..9c98d6ad1e7 100644 --- a/homeassistant/components/totalconnect/translations/no.json +++ b/homeassistant/components/totalconnect/translations/no.json @@ -1,12 +1,25 @@ { "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" + "invalid_auth": "Ugyldig godkjenning", + "usercode": "Brukerkode er ikke gyldig for denne brukeren p\u00e5 dette stedet" }, "step": { + "locations": { + "data": { + "location": "Plassering" + }, + "description": "Angi brukerkoden for denne brukeren p\u00e5 denne plasseringen", + "title": "Brukerkoder for plassering" + }, + "reauth_confirm": { + "description": "Total Connect m\u00e5 godkjenne kontoen din p\u00e5 nytt", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "password": "Passord", diff --git a/homeassistant/components/totalconnect/translations/pl.json b/homeassistant/components/totalconnect/translations/pl.json index 530d632040c..ff2ca2351e6 100644 --- a/homeassistant/components/totalconnect/translations/pl.json +++ b/homeassistant/components/totalconnect/translations/pl.json @@ -1,12 +1,25 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { - "invalid_auth": "Niepoprawne uwierzytelnienie" + "invalid_auth": "Niepoprawne uwierzytelnienie", + "usercode": "Nieprawid\u0142owy kod u\u017cytkownika dla u\u017cytkownika w tej lokalizacji" }, "step": { + "locations": { + "data": { + "location": "Lokalizacja" + }, + "description": "Wprowad\u017a kod u\u017cytkownika dla u\u017cytkownika w tej lokalizacji", + "title": "Kody lokalizacji u\u017cytkownika" + }, + "reauth_confirm": { + "description": "Integracja Total Connect wymaga ponownego uwierzytelnienia Twojego konta", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "password": "Has\u0142o", diff --git a/homeassistant/components/totalconnect/translations/ru.json b/homeassistant/components/totalconnect/translations/ru.json index c5221b5e4ca..0f067541dec 100644 --- a/homeassistant/components/totalconnect/translations/ru.json +++ b/homeassistant/components/totalconnect/translations/ru.json @@ -1,12 +1,25 @@ { "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "usercode": "\u041a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0432 \u044d\u0442\u043e\u043c \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438." }, "step": { + "locations": { + "data": { + "location": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0432 \u044d\u0442\u043e\u043c \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438.", + "title": "\u041a\u043e\u0434\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0434\u043b\u044f \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "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 Total Connect", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b\u044f" + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/totalconnect/translations/zh-Hant.json b/homeassistant/components/totalconnect/translations/zh-Hant.json index c20dd4065b6..96921baf007 100644 --- a/homeassistant/components/totalconnect/translations/zh-Hant.json +++ b/homeassistant/components/totalconnect/translations/zh-Hant.json @@ -1,12 +1,25 @@ { "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" + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "usercode": "\u4f7f\u7528\u8005\u4ee3\u78bc\u4e0d\u652f\u63f4\u6b64\u5ea7\u6a19" }, "step": { + "locations": { + "data": { + "location": "\u5ea7\u6a19" + }, + "description": "\u8f38\u5165\u4f7f\u7528\u8005\u65bc\u6b64\u5ea7\u6a19\u4e4b\u4f7f\u7528\u8005\u4ee3\u78bc", + "title": "\u5ea7\u6a19\u4f7f\u7528\u8005\u4ee3\u78bc" + }, + "reauth_confirm": { + "description": "Total Connect \u9700\u8981\u91cd\u65b0\u8a8d\u8b49\u5e33\u865f", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "password": "\u5bc6\u78bc", diff --git a/homeassistant/components/tplink/translations/ko.json b/homeassistant/components/tplink/translations/ko.json index dc8a6a5a8fc..e1ff7eff372 100644 --- a/homeassistant/components/tplink/translations/ko.json +++ b/homeassistant/components/tplink/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "TP-Link \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 TP-Link \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index c19a9cdd27e..cc598a9851b 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -3,7 +3,12 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from homeassistant.const import CONF_WEBHOOK_ID, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.const import ( + ATTR_ID, + CONF_WEBHOOK_ID, + HTTP_OK, + HTTP_UNPROCESSABLE_ENTITY, +) from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -13,7 +18,6 @@ from .const import ( ATTR_ALTITUDE, ATTR_BATTERY, ATTR_BEARING, - ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SPEED, diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index 56c0ab5ba1d..06dd368b6a3 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -12,7 +12,6 @@ ATTR_BATTERY = "batt" ATTR_BEARING = "bearing" ATTR_CATEGORY = "category" ATTR_GEOFENCE = "geofence" -ATTR_ID = "id" ATTR_LATITUDE = "lat" ATTR_LONGITUDE = "lon" ATTR_MOTION = "motion" diff --git a/homeassistant/components/traccar/strings.json b/homeassistant/components/traccar/strings.json index d9d9fff4bd3..89689ee43df 100644 --- a/homeassistant/components/traccar/strings.json +++ b/homeassistant/components/traccar/strings.json @@ -11,7 +11,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup the webhook feature in Traccar.\n\nUse the following url: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Traccar.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." } } } diff --git a/homeassistant/components/traccar/translations/ca.json b/homeassistant/components/traccar/translations/ca.json index 1b00aab4b3e..62c15e0ca20 100644 --- a/homeassistant/components/traccar/translations/ca.json +++ b/homeassistant/components/traccar/translations/ca.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "La teva inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per poder rebre missatges webhook." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de Traccar.\n\nUtilitza el seg\u00fcent enlla\u00e7: `{webhook_url}`\n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de Traccar.\n\nUtilitza el seg\u00fcent URL: `{webhook_url}`\n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." }, "step": { "user": { diff --git a/homeassistant/components/traccar/translations/en.json b/homeassistant/components/traccar/translations/en.json index 2231d53ceb8..c6d7f0f1892 100644 --- a/homeassistant/components/traccar/translations/en.json +++ b/homeassistant/components/traccar/translations/en.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup the webhook feature in Traccar.\n\nUse the following url: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Traccar.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." }, "step": { "user": { diff --git a/homeassistant/components/traccar/translations/it.json b/homeassistant/components/traccar/translations/it.json index 6d4de5bb13d..8c95b3cd022 100644 --- a/homeassistant/components/traccar/translations/it.json +++ b/homeassistant/components/traccar/translations/it.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "L'istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi webhook." }, "create_entry": { - "default": "Per inviare eventi a Home Assistant, \u00e8 necessario configurare la funzionalit\u00e0 webhook in Traccar.\n\nUtilizzare l'URL seguente: `{webhook_url}`\n\nPer ulteriori dettagli, vedere [la documentazione]({docs_url}) ." + "default": "Per inviare eventi a Home Assistant, \u00e8 necessario impostare la funzione webhook in Traccar.\n\nUsa il seguente URL: `{webhook_url}`.\n\nVedi [la documentazione]({docs_url}) per ulteriori dettagli." }, "step": { "user": { diff --git a/homeassistant/components/traccar/translations/ko.json b/homeassistant/components/traccar/translations/ko.json index 910281d4b38..04e13a9aa6f 100644 --- a/homeassistant/components/traccar/translations/ko.json +++ b/homeassistant/components/traccar/translations/ko.json @@ -1,7 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Traccar \uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c URL \uc815\ubcf4\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4: `{webhook_url}`\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Traccar \uc5d0\uc11c \uc6f9 \ud6c5\uc744 \uc124\uc815\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c\uc758 URL \uc8fc\uc18c\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4: `{webhook_url}`\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/traccar/translations/nl.json b/homeassistant/components/traccar/translations/nl.json index 251e16d0763..0b4563d69fc 100644 --- a/homeassistant/components/traccar/translations/nl.json +++ b/homeassistant/components/traccar/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Voor het verzenden van gebeurtenissen naar Home Assistant, moet u de webhook-functie in Traccar instellen.\n\nGebruik de volgende URL: ' {webhook_url} '\n\nZie [de documentatie] ({docs_url}) voor meer informatie." diff --git a/homeassistant/components/traccar/translations/no.json b/homeassistant/components/traccar/translations/no.json index 38faa4dc1c1..e2051be22b6 100644 --- a/homeassistant/components/traccar/translations/no.json +++ b/homeassistant/components/traccar/translations/no.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Home Assistant forekomsten din m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta webhook meldinger" }, "create_entry": { - "default": "Hvis du vil sende hendelser til Home Assistant, m\u00e5 du konfigurere webhook-funksjonen i Traccar.\n\nBruk f\u00f8lgende URL-adresse: `{webhook_url}`\n\nSe [dokumentasjonen]({docs_url}) for mer informasjon." + "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du konfigurere webhook-funksjonen i Traccar. \n\n Bruk f\u00f8lgende URL: \"{webhook_url}\" \n\n Se [dokumentasjonen] ({docs_url}) for mer informasjon." }, "step": { "user": { diff --git a/homeassistant/components/traccar/translations/ru.json b/homeassistant/components/traccar/translations/ru.json index b35b1e74e1e..dc4cd2cde11 100644 --- a/homeassistant/components/traccar/translations/ru.json +++ b/homeassistant/components/traccar/translations/ru.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f Webhook-\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439." }, "create_entry": { - "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Webhook \u0434\u043b\u044f Traccar.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Webhook \u0434\u043b\u044f Traccar.\n\n\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 URL-\u0430\u0434\u0440\u0435\u0441 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438: `{webhook_url}`\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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/traccar/translations/zh-Hant.json b/homeassistant/components/traccar/translations/zh-Hant.json index 2204e7c3323..ee7c75d8408 100644 --- a/homeassistant/components/traccar/translations/zh-Hant.json +++ b/homeassistant/components/traccar/translations/zh-Hant.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Home Assistant \u5be6\u9ad4\u5fc5\u9808\u8981\u80fd\u5f9e\u7db2\u969b\u7db2\u8def\u5b58\u53d6\u65b9\u80fd\u63a5\u6536 Webhook \u8a0a\u606f\u3002" }, "create_entry": { - "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Traccar \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u4f7f\u7528 url: `{webhook_url}`\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Traccar \u5167\u8a2d\u5b9a Webhook \u529f\u80fd\u3002\n\n\u8acb\u4f7f\u7528 URL\uff1a`{webhook_url}`\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6] ({docs_url}) \u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" }, "step": { "user": { diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 8d82df07bbb..4c984067ada 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -16,7 +16,6 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.json import load_json -from . import config_flow # noqa: F401 from .const import ( ATTR_TRADFRI_GATEWAY, ATTR_TRADFRI_GATEWAY_MODEL, diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index 5c6bf76a169..99b9dff6d22 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -7,5 +7,5 @@ "homekit": { "models": ["TRADFRI"] }, - "codeowners": ["@ggravlingen"] + "codeowners": [] } diff --git a/homeassistant/components/tradfri/translations/ko.json b/homeassistant/components/tradfri/translations/ko.json index caa94fa8b10..067a10c6490 100644 --- a/homeassistant/components/tradfri/translations/ko.json +++ b/homeassistant/components/tradfri/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "\ube0c\ub9ac\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\ube0c\ub9ac\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_key": "\uc81c\uacf5\ub41c \ud0a4\ub85c \ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc774 \ubb38\uc81c\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud574\ubcf4\uc138\uc694.", "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 76d9aedd8d5..5a37cc4d771 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,4 +1,6 @@ """Support for the Transmission BitTorrent client API.""" +from __future__ import annotations + from datetime import timedelta import logging from typing import List @@ -176,7 +178,7 @@ class TransmissionClient: self.unsub_timer = None @property - def api(self) -> "TransmissionData": + def api(self) -> TransmissionData: """Return the TransmissionData object.""" return self._tm_data diff --git a/homeassistant/components/transmission/translations/ko.json b/homeassistant/components/transmission/translations/ko.json index 7f5d67114a1..002e374e54d 100644 --- a/homeassistant/components/transmission/translations/ko.json +++ b/homeassistant/components/transmission/translations/ko.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" }, "step": { diff --git a/homeassistant/components/transmission/translations/nl.json b/homeassistant/components/transmission/translations/nl.json index 8cfa9333ba4..df9a4590e66 100644 --- a/homeassistant/components/transmission/translations/nl.json +++ b/homeassistant/components/transmission/translations/nl.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Kan geen verbinding maken met host", + "invalid_auth": "Ongeldige authenticatie", "name_exists": "Naam bestaat al" }, "step": { diff --git a/homeassistant/components/transmission/translations/ru.json b/homeassistant/components/transmission/translations/ru.json index d1fbd592f0f..6b326bc123c 100644 --- a/homeassistant/components/transmission/translations/ru.json +++ b/homeassistant/components/transmission/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." }, "step": { diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 4b4bd48bfe3..b7079a3311a 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, + CONF_ATTRIBUTE, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -40,7 +41,6 @@ ATTR_INVERT = "invert" ATTR_SAMPLE_DURATION = "sample_duration" ATTR_SAMPLE_COUNT = "sample_count" -CONF_ATTRIBUTE = "attribute" CONF_INVERT = "invert" CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" @@ -66,7 +66,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the trend sensors.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) sensors = [] diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index d278283baaf..f9b07a98595 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import Dict, Optional +from typing import Dict, Optional, cast from aiohttp import web import mutagen @@ -24,6 +24,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_NAME, CONF_PLATFORM, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, @@ -33,8 +34,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import get_url +from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import async_get_integration from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util.yaml import load_yaml # mypy: allow-untyped-defs, no-check-untyped-defs @@ -55,6 +59,9 @@ CONF_LANG = "language" CONF_SERVICE_NAME = "service_name" CONF_TIME_MEMORY = "time_memory" +CONF_DESCRIPTION = "description" +CONF_FIELDS = "fields" + DEFAULT_CACHE = True DEFAULT_CACHE_DIR = "tts" DEFAULT_TIME_MEMORY = 300 @@ -127,6 +134,13 @@ async def async_setup(hass, config): hass.http.register_view(TextToSpeechView(tts)) hass.http.register_view(TextToSpeechUrlView(tts)) + # Load service descriptions from tts/services.yaml + integration = await async_get_integration(hass, DOMAIN) + services_yaml = integration.file_path / "services.yaml" + services_dict = cast( + dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + ) + async def async_setup_platform(p_type, p_config=None, discovery_info=None): """Set up a TTS platform.""" if p_config is None: @@ -193,8 +207,16 @@ async def async_setup(hass, config): DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY ) + # Register the service description + service_desc = { + CONF_NAME: f"Say an TTS message with {p_type}", + CONF_DESCRIPTION: f"Say something using text-to-speech on a media player with {p_type}.", + CONF_FIELDS: services_dict[SERVICE_SAY][CONF_FIELDS], + } + async_set_service_schema(hass, DOMAIN, service_name, service_desc) + setup_tasks = [ - async_setup_platform(p_type, p_config) + asyncio.create_task(async_setup_platform(p_type, p_config)) for p_type, p_config in config_per_platform(config, DOMAIN) ] diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 7d1bf95572b..2b48dd39dee 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -1,23 +1,43 @@ # Describes the format for available TTS services say: - description: Say some things on a media player. + name: Say an TTS message + description: Say something using text-to-speech on a media player. fields: entity_id: + name: Entity description: Name(s) of media player entities. example: "media_player.floor" + required: true + selector: + entity: + domain: media_player message: + name: Message description: Text to speak on devices. example: "My name is hanna" + required: true + selector: + text: cache: + name: Cache description: Control file cache of this message. example: "true" + default: false + selector: + boolean: language: + name: Language description: Language to use for speech generation. example: "ru" + selector: + text: options: - description: A dictionary containing platform-specific options. Optional depending on the platform. + description: + A dictionary containing platform-specific options. Optional depending on + the platform. example: platform specific clear_cache: - description: Remove cache files and RAM cache. + name: Clear TTS cache + description: Remove all text-to-speech cache files and RAM cache. diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 5876331ea97..7f6ba6b26fd 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -392,7 +392,7 @@ class TuyaDevice(Entity): entity_registry.async_remove(self.entity_id) await cleanup_device_registry(self.hass, entity_entry.device_id) else: - await self.async_remove() + await self.async_remove(force_remove=True) @callback def _update_callback(self): diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index da851d4a776..73ba69da797 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,6 +1,5 @@ """Support for the Tuya climate devices.""" from datetime import timedelta -import logging from homeassistant.components.climate import ( DOMAIN as SENSOR_DOMAIN, @@ -34,7 +33,9 @@ from .const import ( CONF_CURR_TEMP_DIVIDER, CONF_MAX_TEMP, CONF_MIN_TEMP, + CONF_SET_TEMP_DIVIDED, CONF_TEMP_DIVIDER, + CONF_TEMP_STEP_OVERRIDE, DOMAIN, SIGNAL_CONFIG_ENTITY, TUYA_DATA, @@ -56,8 +57,6 @@ TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH} -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up tuya sensors dynamically through tuya discovery.""" @@ -106,6 +105,8 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): self.operations = [HVAC_MODE_OFF] self._has_operation = False self._def_hvac_mode = HVAC_MODE_AUTO + self._set_temp_divided = True + self._temp_step_override = None self._min_temp = None self._max_temp = None @@ -120,6 +121,8 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): self._tuya.set_unit("FAHRENHEIT" if unit == TEMP_FAHRENHEIT else "CELSIUS") self._tuya.temp_divider = config.get(CONF_TEMP_DIVIDER, 0) self._tuya.curr_temp_divider = config.get(CONF_CURR_TEMP_DIVIDER, 0) + self._set_temp_divided = config.get(CONF_SET_TEMP_DIVIDED, True) + self._temp_step_override = config.get(CONF_TEMP_STEP_OVERRIDE) min_temp = config.get(CONF_MIN_TEMP, 0) max_temp = config.get(CONF_MAX_TEMP, 0) if min_temp >= max_temp: @@ -192,6 +195,8 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def target_temperature_step(self): """Return the supported step of target temperature.""" + if self._temp_step_override: + return self._temp_step_override return self._tuya.target_temperature_step() @property @@ -207,7 +212,7 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): def set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: - self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE]) + self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE], self._set_temp_divided) def set_fan_mode(self, fan_mode): """Set new target fan mode.""" diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 5d22a83e03e..b705c2c7c36 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -23,7 +23,6 @@ from homeassistant.const import ( from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -# pylint:disable=unused-import from .const import ( CONF_BRIGHTNESS_RANGE_MODE, CONF_COUNTRYCODE, @@ -35,17 +34,19 @@ from .const import ( CONF_MIN_TEMP, CONF_QUERY_DEVICE, CONF_QUERY_INTERVAL, + CONF_SET_TEMP_DIVIDED, CONF_SUPPORT_COLOR, CONF_TEMP_DIVIDER, + CONF_TEMP_STEP_OVERRIDE, CONF_TUYA_MAX_COLTEMP, DEFAULT_DISCOVERY_INTERVAL, DEFAULT_QUERY_INTERVAL, DEFAULT_TUYA_MAX_COLTEMP, - DOMAIN, TUYA_DATA, TUYA_PLATFORMS, TUYA_TYPE_NOT_QUERY, ) +from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -66,6 +67,7 @@ ERROR_DEV_NOT_FOUND = "dev_not_found" RESULT_AUTH_FAILED = "invalid_auth" RESULT_CONN_ERROR = "cannot_connect" +RESULT_SINGLE_INSTANCE = "single_instance_allowed" RESULT_SUCCESS = "success" RESULT_LOG_MESSAGE = { @@ -123,7 +125,7 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + return self.async_abort(reason=RESULT_SINGLE_INSTANCE) errors = {} @@ -257,7 +259,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if self.config_entry.state != config_entries.ENTRY_STATE_LOADED: _LOGGER.error("Tuya integration not yet loaded") - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason=RESULT_CONN_ERROR) if user_input is not None: dev_ids = user_input.get(CONF_LIST_DEVICES) @@ -323,11 +325,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): def _get_device_schema(self, device_type, curr_conf, device): """Return option schema for device.""" + if device_type != device.device_type(): + return None + schema = None if device_type == "light": - return self._get_light_schema(curr_conf, device) - if device_type == "climate": - return self._get_climate_schema(curr_conf, device) - return None + schema = self._get_light_schema(curr_conf, device) + elif device_type == "climate": + schema = self._get_climate_schema(curr_conf, device) + return schema @staticmethod def _get_light_schema(curr_conf, device): @@ -374,6 +379,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Create option schema for climate device.""" unit = device.temperature_unit() def_unit = TEMP_FAHRENHEIT if unit == "FAHRENHEIT" else TEMP_CELSIUS + supported_steps = device.supported_temperature_steps() + default_step = device.target_temperature_step() config_schema = vol.Schema( { @@ -389,6 +396,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_CURR_TEMP_DIVIDER, default=curr_conf.get(CONF_CURR_TEMP_DIVIDER, 0), ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), + vol.Optional( + CONF_SET_TEMP_DIVIDED, + default=curr_conf.get(CONF_SET_TEMP_DIVIDED, True), + ): bool, + vol.Optional( + CONF_TEMP_STEP_OVERRIDE, + default=curr_conf.get(CONF_TEMP_STEP_OVERRIDE, default_step), + ): vol.In(supported_steps), vol.Optional( CONF_MIN_TEMP, default=curr_conf.get(CONF_MIN_TEMP, 0), diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 4f4ec342b15..646bcc077cf 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -10,8 +10,10 @@ CONF_MIN_KELVIN = "min_kelvin" CONF_MIN_TEMP = "min_temp" CONF_QUERY_DEVICE = "query_device" CONF_QUERY_INTERVAL = "query_interval" +CONF_SET_TEMP_DIVIDED = "set_temp_divided" CONF_SUPPORT_COLOR = "support_color" CONF_TEMP_DIVIDER = "temp_divider" +CONF_TEMP_STEP_OVERRIDE = "temp_step_override" CONF_TUYA_MAX_COLTEMP = "tuya_max_coltemp" DEFAULT_DISCOVERY_INTERVAL = 605 diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index e88349cf795..cb6f96358c9 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,5 +1,6 @@ """Support for Tuya fans.""" from datetime import timedelta +from typing import Optional from homeassistant.components.fan import ( DOMAIN as SENSOR_DOMAIN, @@ -10,6 +11,10 @@ from homeassistant.components.fan import ( ) from homeassistant.const import CONF_PLATFORM, STATE_OFF from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from . import TuyaDevice from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW @@ -61,24 +66,31 @@ class TuyaFanDevice(TuyaDevice, FanEntity): """Init Tuya fan device.""" super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.speeds = [STATE_OFF] + self.speeds = [] async def async_added_to_hass(self): """Create fan list when add to hass.""" await super().async_added_to_hass() self.speeds.extend(self._tuya.speed_list()) - def set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if speed == STATE_OFF: + def set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: self.turn_off() else: - self._tuya.set_speed(speed) + tuya_speed = percentage_to_ordered_list_item(self.speeds, percentage) + self._tuya.set_speed(tuya_speed) - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" - if speed is not None: - self.set_speed(speed) + if percentage is not None: + self.set_percentage(percentage) else: self._tuya.turn_on() @@ -90,6 +102,13 @@ class TuyaFanDevice(TuyaDevice, FanEntity): """Oscillate the fan.""" self._tuya.oscillate(oscillating) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self.speeds is None: + return super().speed_count + return len(self.speeds) + @property def oscillating(self): """Return current oscillating status.""" @@ -105,16 +124,13 @@ class TuyaFanDevice(TuyaDevice, FanEntity): return self._tuya.state() @property - def speed(self) -> str: + def percentage(self) -> Optional[int]: """Return the current speed.""" - if self.is_on: - return self._tuya.speed() - return STATE_OFF - - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return self.speeds + if not self.is_on: + return 0 + if self.speeds is None: + return None + return ordered_list_item_to_percentage(self.speeds, self._tuya.speed()) @property def supported_features(self) -> int: diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 7481e56f00a..e72c7c63112 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -2,7 +2,7 @@ "domain": "tuya", "name": "Tuya", "documentation": "https://www.home-assistant.io/integrations/tuya", - "requirements": ["tuyaha==0.0.9"], + "requirements": ["tuyaha==0.0.10"], "codeowners": ["@ollo69"], "config_flow": true } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 444ff0b5c21..23958349b66 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -49,6 +49,8 @@ "unit_of_measurement": "Temperature unit used by device", "temp_divider": "Temperature values divider (0 = use default)", "curr_temp_divider": "Current Temperature value divider (0 = use default)", + "set_temp_divided": "Use divided Temperature value for set temperature command", + "temp_step_override": "Target Temperature step", "min_temp": "Min target temperature (use min and max = 0 for default)", "max_temp": "Max target temperature (use min and max = 0 for default)" } diff --git a/homeassistant/components/tuya/translations/ca.json b/homeassistant/components/tuya/translations/ca.json index 908cf287eeb..a00d9683141 100644 --- a/homeassistant/components/tuya/translations/ca.json +++ b/homeassistant/components/tuya/translations/ca.json @@ -40,8 +40,10 @@ "max_temp": "Temperatura desitjada m\u00e0xima (utilitza min i max = 0 per defecte)", "min_kelvin": "Temperatura del color m\u00ednima suportada, en Kelvin", "min_temp": "Temperatura desitjada m\u00ednima (utilitza min i max = 0 per defecte)", + "set_temp_divided": "Utilitza el valor de temperatura dividit per a ordres de configuraci\u00f3 de temperatura", "support_color": "For\u00e7a el suport de color", "temp_divider": "Divisor del valor de temperatura (0 = predeterminat)", + "temp_step_override": "Pas de temperatura objectiu", "tuya_max_coltemp": "Temperatura de color m\u00e0xima enviada pel dispositiu", "unit_of_measurement": "Unitat de temperatura utilitzada pel dispositiu" }, diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index 46756b18cb8..7204d6072a9 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -40,8 +40,10 @@ "max_temp": "Max target temperature (use min and max = 0 for default)", "min_kelvin": "Min color temperature supported in kelvin", "min_temp": "Min target temperature (use min and max = 0 for default)", + "set_temp_divided": "Use divided Temperature value for set temperature command", "support_color": "Force color support", "temp_divider": "Temperature values divider (0 = use default)", + "temp_step_override": "Target Temperature step", "tuya_max_coltemp": "Max color temperature reported by device", "unit_of_measurement": "Temperature unit used by device" }, diff --git a/homeassistant/components/tuya/translations/es.json b/homeassistant/components/tuya/translations/es.json index cd8da781870..9c57a216888 100644 --- a/homeassistant/components/tuya/translations/es.json +++ b/homeassistant/components/tuya/translations/es.json @@ -40,8 +40,10 @@ "max_temp": "Temperatura objetivo m\u00e1xima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", "min_kelvin": "Temperatura de color m\u00ednima soportada en kelvin", "min_temp": "Temperatura objetivo m\u00ednima (usa m\u00edn. y m\u00e1x. = 0 por defecto)", + "set_temp_divided": "Use el valor de temperatura dividido para el comando de temperatura establecida", "support_color": "Forzar soporte de color", "temp_divider": "Divisor de los valores de temperatura (0 = usar valor por defecto)", + "temp_step_override": "Temperatura deseada", "tuya_max_coltemp": "Temperatura de color m\u00e1xima notificada por dispositivo", "unit_of_measurement": "Unidad de temperatura utilizada por el dispositivo" }, diff --git a/homeassistant/components/tuya/translations/et.json b/homeassistant/components/tuya/translations/et.json index 967b38cdb82..48161f552b8 100644 --- a/homeassistant/components/tuya/translations/et.json +++ b/homeassistant/components/tuya/translations/et.json @@ -12,9 +12,9 @@ "step": { "user": { "data": { - "country_code": "Teie konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", + "country_code": "Konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", "password": "Salas\u00f5na", - "platform": "\u00c4pp kus teie konto registreeriti", + "platform": "\u00c4pp kus konto registreeriti", "username": "Kasutajanimi" }, "description": "Sisesta oma Tuya konto andmed.", @@ -40,8 +40,10 @@ "max_temp": "Maksimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", "min_kelvin": "Minimaalne v\u00f5imalik v\u00e4rvitemperatuur (Kelvinites)", "min_temp": "Minimaalne sihttemperatuur (vaikimisi kasuta min ja max = 0)", + "set_temp_divided": "M\u00e4\u00e4ratud temperatuuri k\u00e4su jaoks kasuta jagatud temperatuuri v\u00e4\u00e4rtust", "support_color": "Luba v\u00e4rvuse juhtimine", "temp_divider": "Temperatuuri v\u00e4\u00e4rtuse eraldaja (0 = kasuta vaikev\u00e4\u00e4rtust)", + "temp_step_override": "Sihttemperatuuri samm", "tuya_max_coltemp": "Seadme teatatud maksimaalne v\u00e4rvitemperatuur", "unit_of_measurement": "Seadme temperatuuri\u00fchik" }, diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json index 9ef1c325d1e..1681343f3b7 100644 --- a/homeassistant/components/tuya/translations/fr.json +++ b/homeassistant/components/tuya/translations/fr.json @@ -40,8 +40,10 @@ "max_temp": "Temp\u00e9rature cible maximale (utilisez min et max = 0 par d\u00e9faut)", "min_kelvin": "Temp\u00e9rature de couleur minimale prise en charge en kelvin", "min_temp": "Temp\u00e9rature cible minimale (utilisez min et max = 0 par d\u00e9faut)", + "set_temp_divided": "Utilisez la valeur de temp\u00e9rature divis\u00e9e pour la commande de temp\u00e9rature d\u00e9finie", "support_color": "Forcer la prise en charge des couleurs", "temp_divider": "Diviseur de valeurs de temp\u00e9rature (0 = utiliser la valeur par d\u00e9faut)", + "temp_step_override": "Pas de temp\u00e9rature cible", "tuya_max_coltemp": "Temp\u00e9rature de couleur maximale rapport\u00e9e par l'appareil", "unit_of_measurement": "Unit\u00e9 de temp\u00e9rature utilis\u00e9e par l'appareil" }, diff --git a/homeassistant/components/tuya/translations/it.json b/homeassistant/components/tuya/translations/it.json index 639f5834922..729514d3541 100644 --- a/homeassistant/components/tuya/translations/it.json +++ b/homeassistant/components/tuya/translations/it.json @@ -40,8 +40,10 @@ "max_temp": "Temperatura di destinazione massima (utilizzare min e max = 0 per impostazione predefinita)", "min_kelvin": "Temperatura colore minima supportata in kelvin", "min_temp": "Temperatura di destinazione minima (utilizzare min e max = 0 per impostazione predefinita)", + "set_temp_divided": "Utilizzare il valore temperatura diviso per impostare il comando temperatura", "support_color": "Forza il supporto del colore", "temp_divider": "Divisore dei valori di temperatura (0 = utilizzare il valore predefinito)", + "temp_step_override": "Passo della temperatura da raggiungere", "tuya_max_coltemp": "Temperatura di colore massima riportata dal dispositivo", "unit_of_measurement": "Unit\u00e0 di temperatura utilizzata dal dispositivo" }, diff --git a/homeassistant/components/tuya/translations/ko.json b/homeassistant/components/tuya/translations/ko.json index e123bc2b6f9..81dd2689b0c 100644 --- a/homeassistant/components/tuya/translations/ko.json +++ b/homeassistant/components/tuya/translations/ko.json @@ -1,8 +1,13 @@ { "config": { "abort": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "flow_title": "Tuya \uad6c\uc131\ud558\uae30", "step": { "user": { @@ -16,5 +21,10 @@ "title": "Tuya" } } + }, + "options": { + "abort": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + } } } \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index 5a0e3691d5b..46a0228843b 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -2,8 +2,12 @@ "config": { "abort": { "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", "single_instance_allowed": "Al geconfigureerd. Er is maar een configuratie mogelijk." }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, "flow_title": "Tuya-configuratie", "step": { "user": { @@ -19,7 +23,16 @@ } }, "options": { + "abort": { + "cannot_connect": "Kan geen verbinding maken" + }, + "error": { + "dev_not_found": "Apparaat niet gevonden" + }, "step": { + "device": { + "title": "Configureer Tuya Apparaat" + }, "init": { "title": "Configureer Tuya opties" } diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index d0c1a3ca188..d02a88f4097 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -40,8 +40,10 @@ "max_temp": "Maks m\u00e5ltemperatur (bruk min og maks = 0 for standard)", "min_kelvin": "Min fargetemperatur st\u00f8ttet i kelvin", "min_temp": "Min m\u00e5ltemperatur (bruk min og maks = 0 for standard)", + "set_temp_divided": "Bruk delt temperaturverdi for innstilt temperaturkommando", "support_color": "Tving fargest\u00f8tte", "temp_divider": "Deler temperaturverdier (0 = bruk standard)", + "temp_step_override": "Trinn for m\u00e5ltemperatur", "tuya_max_coltemp": "Maks fargetemperatur rapportert av enheten", "unit_of_measurement": "Temperaturenhet som brukes av enheten" }, diff --git a/homeassistant/components/tuya/translations/pl.json b/homeassistant/components/tuya/translations/pl.json index a24c1dbe265..742ac600c62 100644 --- a/homeassistant/components/tuya/translations/pl.json +++ b/homeassistant/components/tuya/translations/pl.json @@ -40,6 +40,7 @@ "max_temp": "Maksymalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", "min_kelvin": "Minimalna obs\u0142ugiwana temperatura barwy w kelwinach", "min_temp": "Minimalna temperatura docelowa (u\u017cyj min i max = 0 dla warto\u015bci domy\u015blnej)", + "set_temp_divided": "U\u017cyj podzielonej warto\u015bci temperatury dla polecenia ustawienia temperatury", "support_color": "Wymu\u015b obs\u0142ug\u0119 kolor\u00f3w", "temp_divider": "Dzielnik warto\u015bci temperatury (0 = u\u017cyj warto\u015bci domy\u015blnej)", "tuya_max_coltemp": "Maksymalna temperatura barwy raportowana przez urz\u0105dzenie", @@ -53,7 +54,7 @@ "discovery_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania nowych urz\u0105dze\u0144 (w sekundach)", "list_devices": "Wybierz urz\u0105dzenia do skonfigurowania lub pozostaw puste, aby zapisa\u0107 konfiguracj\u0119", "query_device": "Wybierz urz\u0105dzenie, kt\u00f3re b\u0119dzie u\u017cywa\u0107 metody odpytywania w celu szybszej aktualizacji statusu", - "query_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania odpytywanego urz\u0105dzenia (w sekundach)" + "query_interval": "Cz\u0119stotliwo\u015b\u0107 skanowania odpytywanego urz\u0105dzenia w sekundach" }, "description": "Nie ustawiaj zbyt niskich warto\u015bci skanowania, bo zako\u0144cz\u0105 si\u0119 niepowodzeniem, generuj\u0105c komunikat o b\u0142\u0119dzie w logu", "title": "Konfiguracja opcji Tuya" diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json index b98c6c8e9cd..4babc23f2ec 100644 --- a/homeassistant/components/tuya/translations/ru.json +++ b/homeassistant/components/tuya/translations/ru.json @@ -2,11 +2,11 @@ "config": { "abort": { "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "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": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tuya", "step": { @@ -40,8 +40,10 @@ "max_temp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", "min_kelvin": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u0432 \u043a\u0435\u043b\u044c\u0432\u0438\u043d\u0430\u0445)", "min_temp": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0435\u043b\u0435\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 min \u0438 max = 0)", + "set_temp_divided": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b \u0434\u043b\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", "support_color": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u0446\u0432\u0435\u0442\u0430", "temp_divider": "\u0414\u0435\u043b\u0438\u0442\u0435\u043b\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b (0 = \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e)", + "temp_step_override": "\u0428\u0430\u0433 \u0446\u0435\u043b\u0435\u0432\u043e\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", "tuya_max_coltemp": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f \u0446\u0432\u0435\u0442\u043e\u0432\u0430\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430, \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c" }, diff --git a/homeassistant/components/tuya/translations/zh-Hant.json b/homeassistant/components/tuya/translations/zh-Hant.json index 08871c3108e..7221c86eb63 100644 --- a/homeassistant/components/tuya/translations/zh-Hant.json +++ b/homeassistant/components/tuya/translations/zh-Hant.json @@ -40,8 +40,10 @@ "max_temp": "\u6700\u9ad8\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", "min_kelvin": "Kelvin \u652f\u63f4\u6700\u4f4e\u8272\u6eab", "min_temp": "\u6700\u4f4e\u76ee\u6a19\u8272\u6eab\uff08\u4f7f\u7528\u6700\u4f4e\u8207\u6700\u9ad8 = 0 \u4f7f\u7528\u9810\u8a2d\uff09", + "set_temp_divided": "\u4f7f\u7528\u5206\u9694\u865f\u6eab\u5ea6\u503c\u4ee5\u57f7\u884c\u8a2d\u5b9a\u6eab\u5ea6\u6307\u4ee4", "support_color": "\u5f37\u5236\u8272\u6eab\u652f\u63f4", "temp_divider": "\u8272\u6eab\u503c\u5206\u914d\u5668\uff080 = \u4f7f\u7528\u9810\u8a2d\uff09", + "temp_step_override": "\u76ee\u6a19\u6eab\u5ea6\u8a2d\u5b9a", "tuya_max_coltemp": "\u88dd\u7f6e\u56de\u5831\u6700\u9ad8\u8272\u6eab", "unit_of_measurement": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u6eab\u5ea6\u55ae\u4f4d" }, diff --git a/homeassistant/components/twentemilieu/services.yaml b/homeassistant/components/twentemilieu/services.yaml index 7a5b1db301d..6227bad1b6d 100644 --- a/homeassistant/components/twentemilieu/services.yaml +++ b/homeassistant/components/twentemilieu/services.yaml @@ -1,6 +1,11 @@ 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/ko.json b/homeassistant/components/twentemilieu/translations/ko.json index 3efc227abf7..e6c19d40d06 100644 --- a/homeassistant/components/twentemilieu/translations/ko.json +++ b/homeassistant/components/twentemilieu/translations/ko.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_address": "Twente Milieu \uc11c\ube44\uc2a4 \uc9c0\uc5ed\uc5d0\uc11c \uc8fc\uc18c\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { diff --git a/homeassistant/components/twentemilieu/translations/nl.json b/homeassistant/components/twentemilieu/translations/nl.json index ca5abd7e37c..54611aa9ab8 100644 --- a/homeassistant/components/twentemilieu/translations/nl.json +++ b/homeassistant/components/twentemilieu/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Locatie is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_address": "Adres niet gevonden in servicegebied Twente Milieu." }, "step": { diff --git a/homeassistant/components/twilio/translations/ko.json b/homeassistant/components/twilio/translations/ko.json index b6be32e1de4..72165dfb798 100644 --- a/homeassistant/components/twilio/translations/ko.json +++ b/homeassistant/components/twilio/translations/ko.json @@ -1,11 +1,15 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4.", + "webhook_not_internet_accessible": "\uc6f9 \ud6c5 \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4." + }, "create_entry": { "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio \uc6f9 \ud6c5]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "step": { "user": { - "description": "Twilio \ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "\uc124\uc815\uc744 \uc2dc\uc791\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Twilio \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" } } diff --git a/homeassistant/components/twilio/translations/nl.json b/homeassistant/components/twilio/translations/nl.json index ee97ef4f6cd..55db4ef5e48 100644 --- a/homeassistant/components/twilio/translations/nl.json +++ b/homeassistant/components/twilio/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk.", + "webhook_not_internet_accessible": "Uw Home Assistant-instantie moet toegankelijk zijn via internet om webhook-berichten te ontvangen." }, "create_entry": { "default": "Om evenementen naar de Home Assistant te verzenden, moet u [Webhooks with Twilio] ( {twilio_url} ) instellen. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhoudstype: application / x-www-form-urlencoded \n\n Zie [de documentatie] ( {docs_url} ) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." diff --git a/homeassistant/components/twinkly/translations/fr.json b/homeassistant/components/twinkly/translations/fr.json index 5071b7e302a..c26edea54ee 100644 --- a/homeassistant/components/twinkly/translations/fr.json +++ b/homeassistant/components/twinkly/translations/fr.json @@ -11,7 +11,8 @@ "data": { "host": "Nom r\u00e9seau (ou adresse IP) de votre Twinkly" }, - "description": "Configurer votre Twinkly" + "description": "Configurer votre Twinkly", + "title": "Twinkly" } } } diff --git a/homeassistant/components/twinkly/translations/ko.json b/homeassistant/components/twinkly/translations/ko.json new file mode 100644 index 00000000000..207037cba60 --- /dev/null +++ b/homeassistant/components/twinkly/translations/ko.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "device_exists": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twinkly/translations/nl.json b/homeassistant/components/twinkly/translations/nl.json index 861ee57283c..97a55150447 100644 --- a/homeassistant/components/twinkly/translations/nl.json +++ b/homeassistant/components/twinkly/translations/nl.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "device_exists": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index acd47253b82..873c9e12ab1 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -2,6 +2,6 @@ "domain": "twitter", "name": "Twitter", "documentation": "https://www.home-assistant.io/integrations/twitter", - "requirements": ["TwitterAPI==2.6.3"], + "requirements": ["TwitterAPI==2.6.6"], "codeowners": [] } diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 4cefefc2f96..12c986d57bb 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -1,9 +1,9 @@ """Support for OpenWRT (ubus) routers.""" -import json + import logging import re -import requests +from openwrt.ubus import Ubus import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -11,8 +11,7 @@ from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_OK -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,7 @@ def _refresh_on_access_denied(func): "Invalid session detected." " Trying to refresh session_id and re-run RPC" ) - self.session_id = _get_session_id(self.url, self.username, self.password) + self.ubus.connect() return func(self, *args, **kwargs) @@ -82,10 +81,10 @@ class UbusDeviceScanner(DeviceScanner): self.last_results = {} self.url = f"http://{host}/ubus" - self.session_id = _get_session_id(self.url, self.username, self.password) + self.ubus = Ubus(self.url, self.username, self.password) self.hostapd = [] self.mac2name = None - self.success_init = self.session_id is not None + self.success_init = self.ubus.connect() is not None def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -119,16 +118,14 @@ class UbusDeviceScanner(DeviceScanner): _LOGGER.info("Checking hostapd") if not self.hostapd: - hostapd = _req_json_rpc(self.url, self.session_id, "list", "hostapd.*", "") + hostapd = self.ubus.get_hostapd() self.hostapd.extend(hostapd.keys()) self.last_results = [] results = 0 # for each access point for hostapd in self.hostapd: - result = _req_json_rpc( - self.url, self.session_id, "call", hostapd, "get_clients" - ) + result = self.ubus.get_hostapd_clients(hostapd) if result: results = results + 1 @@ -151,31 +148,21 @@ class DnsmasqUbusDeviceScanner(UbusDeviceScanner): def _generate_mac2name(self): if self.leasefile is None: - result = _req_json_rpc( - self.url, - self.session_id, - "call", - "uci", - "get", - config="dhcp", - type="dnsmasq", - ) + result = self.ubus.get_uci_config("dhcp", "dnsmasq") if result: values = result["values"].values() self.leasefile = next(iter(values))["leasefile"] else: return - result = _req_json_rpc( - self.url, self.session_id, "call", "file", "read", path=self.leasefile - ) + result = self.ubus.file_read(self.leasefile) if result: self.mac2name = {} for line in result["data"].splitlines(): hosts = line.split(" ") self.mac2name[hosts[1].upper()] = hosts[3] else: - # Error, handled in the _req_json_rpc + # Error, handled in the ubus.file_read() return @@ -183,7 +170,7 @@ class OdhcpdUbusDeviceScanner(UbusDeviceScanner): """Implement the Ubus device scanning for the odhcp DHCP server.""" def _generate_mac2name(self): - result = _req_json_rpc(self.url, self.session_id, "call", "dhcp", "ipv4leases") + result = self.ubus.get_dhcp_method("ipv4leases") if result: self.mac2name = {} for device in result["device"].values(): @@ -193,55 +180,5 @@ class OdhcpdUbusDeviceScanner(UbusDeviceScanner): mac = ":".join(mac[i : i + 2] for i in range(0, len(mac), 2)) self.mac2name[mac.upper()] = lease["hostname"] else: - # Error, handled in the _req_json_rpc + # Error, handled in the ubus.get_dhcp_method() return - - -def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): - """Perform one JSON RPC operation.""" - data = json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": rpcmethod, - "params": [session_id, subsystem, method, params], - } - ) - - try: - res = requests.post(url, data=data, timeout=5) - - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - return - - if res.status_code == HTTP_OK: - response = res.json() - if "error" in response: - if ( - "message" in response["error"] - and response["error"]["message"] == "Access denied" - ): - raise PermissionError(response["error"]["message"]) - raise HomeAssistantError(response["error"]["message"]) - - if rpcmethod == "call": - try: - return response["result"][1] - except IndexError: - return - else: - return response["result"] - - -def _get_session_id(url, username, password): - """Get the authentication token for the given host+username+password.""" - res = _req_json_rpc( - url, - "00000000000000000000000000000000", - "call", - "session", - "login", - username=username, - password=password, - ) - return res["ubus_rpc_session"] diff --git a/homeassistant/components/ubus/manifest.json b/homeassistant/components/ubus/manifest.json index af7fb50b6c4..68452f98f7d 100644 --- a/homeassistant/components/ubus/manifest.json +++ b/homeassistant/components/ubus/manifest.json @@ -2,5 +2,6 @@ "domain": "ubus", "name": "OpenWrt (ubus)", "documentation": "https://www.home-assistant.io/integrations/ubus", - "codeowners": [] + "requirements": ["openwrt-ubus-rpc==0.0.2"], + "codeowners": ["@noltari"] } diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 439073497a2..8d24a9b642f 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,11 +1,11 @@ -"""Support for devices connected to UniFi POE.""" +"""Integration to UniFi controllers and its various features.""" from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .config_flow import get_controller_id_from_config_entry from .const import ( ATTR_MANUFACTURER, + CONF_CONTROLLER, DOMAIN as UNIFI_DOMAIN, LOGGER, UNIFI_WIRELESS_CLIENTS, @@ -29,10 +29,19 @@ async def async_setup_entry(hass, config_entry): """Set up the UniFi component.""" hass.data.setdefault(UNIFI_DOMAIN, {}) + # Flat configuration was introduced with 2021.3 + await async_flatten_entry_data(hass, config_entry) + controller = UniFiController(hass, config_entry) if not await controller.async_setup(): return False + # Unique ID was introduced with 2021.3 + if config_entry.unique_id is None: + hass.config_entries.async_update_entry( + config_entry, unique_id=controller.site_id + ) + hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) @@ -60,6 +69,17 @@ async def async_unload_entry(hass, config_entry): return await controller.async_reset() +async def async_flatten_entry_data(hass, config_entry): + """Simpler configuration structure for entry data. + + Keep controller key layer in case user rollbacks. + """ + + data: dict = {**config_entry.data, **config_entry.data[CONF_CONTROLLER]} + if config_entry.data != data: + hass.config_entries.async_update_entry(config_entry, data=data) + + class UnifiWirelessClients: """Class to store clients known to be wireless. @@ -82,21 +102,12 @@ class UnifiWirelessClients: @callback def get_data(self, config_entry): """Get data related to a specific controller.""" - controller_id = get_controller_id_from_config_entry(config_entry) - key = config_entry.entry_id - if controller_id in self.data: - key = controller_id - - data = self.data.get(key, {"wireless_devices": []}) + data = self.data.get(config_entry.entry_id, {"wireless_devices": []}) return set(data["wireless_devices"]) @callback def update_data(self, data, config_entry): """Update data and schedule to save to file.""" - controller_id = get_controller_id_from_config_entry(config_entry) - if controller_id in self.data: - self.data.pop(controller_id) - self.data[config_entry.entry_id] = {"wireless_devices": list(data)} self._store.async_delay_save(self._data_to_save, SAVE_DELAY) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 0ea55c15747..5a0a4969f09 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,4 +1,10 @@ -"""Config flow for UniFi.""" +"""Config flow for UniFi. + +Provides user initiated configuration flow. +Discovery of controllers hosted on UDM and UDM Pro devices through SSDP. +Reauthentication when issue with credentials are reported. +Configuration of options through options flow. +""" import socket from urllib.parse import urlparse @@ -31,16 +37,14 @@ from .const import ( CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, - CONTROLLER_ID, DEFAULT_DPI_RESTRICTIONS, DEFAULT_POE_CLIENTS, DOMAIN as UNIFI_DOMAIN, - LOGGER, ) from .controller import get_controller -from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect +from .errors import AuthenticationRequired, CannotConnect -DEFAULT_PORT = 8443 +DEFAULT_PORT = 443 DEFAULT_SITE_ID = "default" DEFAULT_VERIFY_SSL = False @@ -51,15 +55,6 @@ MODEL_PORTS = { } -@callback -def get_controller_id_from_config_entry(config_entry): - """Return controller with a matching bridge id.""" - return CONTROLLER_ID.format( - host=config_entry.data[CONF_CONTROLLER][CONF_HOST], - site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID], - ) - - class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): """Handle a UniFi config flow.""" @@ -75,9 +70,9 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): def __init__(self): """Initialize the UniFi flow.""" self.config = {} - self.sites = None - self.reauth_config_entry = {} - self.reauth_config = {} + self.site_ids = {} + self.site_names = {} + self.reauth_config_entry = None self.reauth_schema = {} async def async_step_user(self, user_input=None): @@ -86,27 +81,27 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): if user_input is not None: - try: - self.config = { - CONF_HOST: user_input[CONF_HOST], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_PORT: user_input.get(CONF_PORT), - CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), - CONF_SITE_ID: DEFAULT_SITE_ID, - } + self.config = { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PORT: user_input.get(CONF_PORT), + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), + CONF_SITE_ID: DEFAULT_SITE_ID, + } - controller = await get_controller(self.hass, **self.config) + try: + controller = await get_controller( + self.hass, + host=self.config[CONF_HOST], + username=self.config[CONF_USERNAME], + password=self.config[CONF_PASSWORD], + port=self.config[CONF_PORT], + site=self.config[CONF_SITE_ID], + verify_ssl=self.config[CONF_VERIFY_SSL], + ) sites = await controller.sites() - self.sites = {site["name"]: site["desc"] for site in sites.values()} - - if self.reauth_config.get(CONF_SITE_ID) in self.sites: - return await self.async_step_site( - {CONF_SITE_ID: self.reauth_config[CONF_SITE_ID]} - ) - - return await self.async_step_site() except AuthenticationRequired: errors["base"] = "faulty_credentials" @@ -114,12 +109,19 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): except CannotConnect: errors["base"] = "service_unavailable" - except Exception: # pylint: disable=broad-except - LOGGER.error( - "Unknown error connecting with UniFi Controller at %s", - user_input[CONF_HOST], - ) - return self.async_abort(reason="unknown") + else: + self.site_ids = {site["_id"]: site["name"] for site in sites.values()} + self.site_names = {site["_id"]: site["desc"] for site in sites.values()} + + if ( + self.reauth_config_entry + and self.reauth_config_entry.unique_id in self.site_names + ): + return await self.async_step_site( + {CONF_SITE_ID: self.reauth_config_entry.unique_id} + ) + + return await self.async_step_site() host = self.config.get(CONF_HOST) if not host and await async_discover_unifi(self.hass): @@ -146,67 +148,72 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors = {} if user_input is not None: - try: - self.config[CONF_SITE_ID] = user_input[CONF_SITE_ID] - data = {CONF_CONTROLLER: self.config} - if self.reauth_config_entry: - self.hass.config_entries.async_update_entry( - self.reauth_config_entry, data=data - ) - await self.hass.config_entries.async_reload( - self.reauth_config_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") + unique_id = user_input[CONF_SITE_ID] + self.config[CONF_SITE_ID] = self.site_ids[unique_id] + # Backwards compatible config + self.config[CONF_CONTROLLER] = self.config.copy() - for entry in self._async_current_entries(): - controller = entry.data[CONF_CONTROLLER] - if ( - controller[CONF_HOST] == self.config[CONF_HOST] - and controller[CONF_SITE_ID] == self.config[CONF_SITE_ID] - ): - raise AlreadyConfigured + config_entry = await self.async_set_unique_id(unique_id) + abort_reason = "configuration_updated" - site_nice_name = self.sites[self.config[CONF_SITE_ID]] - return self.async_create_entry(title=site_nice_name, data=data) + if self.reauth_config_entry: + config_entry = self.reauth_config_entry + abort_reason = "reauth_successful" - except AlreadyConfigured: - return self.async_abort(reason="already_configured") + if config_entry: + controller = self.hass.data.get(UNIFI_DOMAIN, {}).get( + config_entry.entry_id + ) - if len(self.sites) == 1: - return await self.async_step_site({CONF_SITE_ID: next(iter(self.sites))}) + if controller and controller.available: + return self.async_abort(reason="already_configured") + + self.hass.config_entries.async_update_entry( + config_entry, data=self.config + ) + await self.hass.config_entries.async_reload(config_entry.entry_id) + return self.async_abort(reason=abort_reason) + + site_nice_name = self.site_names[unique_id] + return self.async_create_entry(title=site_nice_name, data=self.config) + + if len(self.site_names) == 1: + return await self.async_step_site( + {CONF_SITE_ID: next(iter(self.site_names))} + ) return self.async_show_form( step_id="site", - data_schema=vol.Schema({vol.Required(CONF_SITE_ID): vol.In(self.sites)}), + data_schema=vol.Schema( + {vol.Required(CONF_SITE_ID): vol.In(self.site_names)} + ), errors=errors, ) async def async_step_reauth(self, config_entry: dict): """Trigger a reauthentication flow.""" self.reauth_config_entry = config_entry - self.reauth_config = config_entry.data[CONF_CONTROLLER] - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { - CONF_HOST: self.reauth_config[CONF_HOST], + CONF_HOST: config_entry.data[CONF_HOST], CONF_SITE_ID: config_entry.title, } self.reauth_schema = { - vol.Required(CONF_HOST, default=self.reauth_config[CONF_HOST]): str, - vol.Required(CONF_USERNAME, default=self.reauth_config[CONF_USERNAME]): str, + vol.Required(CONF_HOST, default=config_entry.data[CONF_HOST]): str, + vol.Required(CONF_USERNAME, default=config_entry.data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_PORT, default=self.reauth_config[CONF_PORT]): int, + vol.Required(CONF_PORT, default=config_entry.data[CONF_PORT]): int, vol.Required( - CONF_VERIFY_SSL, default=self.reauth_config[CONF_VERIFY_SSL] + CONF_VERIFY_SSL, default=config_entry.data[CONF_VERIFY_SSL] ): bool, } return await self.async_step_user() async def async_step_ssdp(self, discovery_info): - """Handle a discovered unifi device.""" + """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]) @@ -219,12 +226,11 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): return self.async_abort(reason="already_configured") await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.config[CONF_HOST]}) + self._abort_if_unique_id_configured(updates=self.config) - # pylint: disable=no-member self.context["title_placeholders"] = { CONF_HOST: self.config[CONF_HOST], - CONF_SITE_ID: "default", + CONF_SITE_ID: DEFAULT_SITE_ID, } port = MODEL_PORTS.get(model_description) @@ -234,11 +240,9 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): return await self.async_step_user() def _host_already_configured(self, host): - """See if we already have a unifi entry matching the host.""" + """See if we already have a UniFi entry matching the host.""" for entry in self._async_current_entries(): - if not entry.data or CONF_CONTROLLER not in entry.data: - continue - if entry.data[CONF_CONTROLLER][CONF_HOST] == host: + if entry.data.get(CONF_HOST) == host: return True return False @@ -263,7 +267,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_simple_options() async def async_step_simple_options(self, user_input=None): - """For simple Jack.""" + """For users without advanced settings enabled.""" if user_input is not None: self.options.update(user_input) return await self._update_options() diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index ba16612a903..94e2fad35ed 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -4,8 +4,6 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "unifi" -CONTROLLER_ID = "{host}-{site}" - CONF_CONTROLLER = "controller" CONF_SITE_ID = "site" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 11e02d60a3f..128f0107984 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -29,7 +29,13 @@ from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_HOST +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -41,7 +47,6 @@ from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, - CONF_CONTROLLER, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -51,7 +56,6 @@ from .const import ( CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, - CONTROLLER_ID, DEFAULT_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS, DEFAULT_DETECTION_TIME, @@ -96,6 +100,7 @@ class UniFiController: self.wireless_clients = None self.listeners = [] + self.site_id: str = "" self._site_name = None self._site_role = None @@ -109,9 +114,10 @@ class UniFiController: def load_config_entry_options(self): """Store attributes to avoid property call overhead since they are called frequently.""" - # Device tracker options options = self.config_entry.options + # Device tracker options + # Config entry option to not track clients. self.option_track_clients = options.get( CONF_TRACK_CLIENTS, DEFAULT_TRACK_CLIENTS @@ -157,20 +163,15 @@ class UniFiController: CONF_ALLOW_UPTIME_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS ) - @property - def controller_id(self): - """Return the controller ID.""" - return CONTROLLER_ID.format(host=self.host, site=self.site) - @property def host(self): """Return the host of this controller.""" - return self.config_entry.data[CONF_CONTROLLER][CONF_HOST] + return self.config_entry.data[CONF_HOST] @property def site(self): """Return the site of this config entry.""" - return self.config_entry.data[CONF_CONTROLLER][CONF_SITE_ID] + return self.config_entry.data[CONF_SITE_ID] @property def site_name(self): @@ -260,25 +261,25 @@ class UniFiController: @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" - return f"unifi-reachable-{self.controller_id}" + return f"unifi-reachable-{self.config_entry.entry_id}" @property - def signal_update(self): + def signal_update(self) -> str: """Event specific per UniFi entry to signal new data.""" - return f"unifi-update-{self.controller_id}" + return f"unifi-update-{self.config_entry.entry_id}" @property - def signal_remove(self): + def signal_remove(self) -> str: """Event specific per UniFi entry to signal removal of entities.""" - return f"unifi-remove-{self.controller_id}" + return f"unifi-remove-{self.config_entry.entry_id}" @property - def signal_options_update(self): + def signal_options_update(self) -> str: """Event specific per UniFi entry to signal new options.""" - return f"unifi-options-{self.controller_id}" + return f"unifi-options-{self.config_entry.entry_id}" @property - def signal_heartbeat_missed(self): + def signal_heartbeat_missed(self) -> str: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" @@ -303,20 +304,18 @@ class UniFiController: try: self.api = await get_controller( self.hass, - **self.config_entry.data[CONF_CONTROLLER], + host=self.config_entry.data[CONF_HOST], + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + port=self.config_entry.data[CONF_PORT], + site=self.config_entry.data[CONF_SITE_ID], + verify_ssl=self.config_entry.data[CONF_VERIFY_SSL], async_callback=self.async_unifi_signalling_callback, ) await self.api.initialize() sites = await self.api.sites() - - for site in sites.values(): - if self.site == site["name"]: - self._site_name = site["desc"] - break - description = await self.api.site_description() - self._site_role = description[0]["site_role"] except CannotConnect as err: raise ConfigEntryNotReady from err @@ -331,6 +330,14 @@ class UniFiController: ) return False + for site in sites.values(): + if self.site == site["name"]: + self.site_id = site["_id"] + self._site_name = site["desc"] + break + + self._site_role = description[0]["site_role"] + # Restore clients that is not a part of active clients list. entity_registry = await self.hass.helpers.entity_registry.async_get_registry() for entity in entity_registry.entities.values(): @@ -452,10 +459,18 @@ class UniFiController: """ self.api.stop_websocket() - for platform in SUPPORTED_PLATFORMS: - await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, platform + unload_ok = all( + await asyncio.gather( + *[ + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, platform + ) + for platform in SUPPORTED_PLATFORMS + ] ) + ) + if not unload_ok: + return False for unsub_dispatcher in self.listeners: unsub_dispatcher() diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 6a4d986d5b2..ac28f7475f6 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,4 +1,4 @@ -"""Track devices using UniFi controllers.""" +"""Track both clients and devices using UniFi controllers.""" from datetime import timedelta from aiounifi.api import SOURCE_DATA, SOURCE_EVENT @@ -145,6 +145,7 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): self.heartbeat_check = False self._is_connected = False + self._controller_connection_state_changed = False if client.last_seen: self._is_connected = ( @@ -175,14 +176,16 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): @callback def async_signal_reachable_callback(self) -> None: """Call when controller connection state change.""" - self.async_update_callback(controller_state_change=True) + self._controller_connection_state_changed = True + super().async_signal_reachable_callback() - # pylint: disable=arguments-differ @callback - def async_update_callback(self, controller_state_change: bool = False) -> None: + def async_update_callback(self) -> None: """Update the clients state.""" - if controller_state_change: + if self._controller_connection_state_changed: + self._controller_connection_state_changed = False + if self.controller.available: self.schedule_update = True @@ -304,6 +307,7 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): self.device = self._item self._is_connected = device.state == 1 + self._controller_connection_state_changed = False self.schedule_update = False async def async_added_to_hass(self) -> None: @@ -325,14 +329,16 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): @callback def async_signal_reachable_callback(self) -> None: """Call when controller connection state change.""" - self.async_update_callback(controller_state_change=True) + self._controller_connection_state_changed = True + super().async_signal_reachable_callback() - # pylint: disable=arguments-differ @callback - def async_update_callback(self, controller_state_change: bool = False) -> None: + def async_update_callback(self) -> None: """Update the devices' state.""" - if controller_state_change: + if self._controller_connection_state_changed: + self._controller_connection_state_changed = False + if self.controller.available: if self._is_connected: self.schedule_update = True diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index c0b8cea09c2..f78ec614da1 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -1,4 +1,8 @@ -"""Support for bandwidth sensors with UniFi clients.""" +"""Sensor platform for UniFi integration. + +Support for bandwidth sensors of network clients. +Support for uptime sensors of network clients. +""" from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP, DOMAIN from homeassistant.const import DATA_MEGABYTES from homeassistant.core import callback diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 15cc2fb45e7..be0bda37971 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -21,6 +21,7 @@ }, "abort": { "already_configured": "Controller site is already configured", + "configuration_updated": "Configuration updated.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 6aa42b0d291..e596e0b1e2a 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -1,4 +1,9 @@ -"""Support for devices connected to UniFi POE.""" +"""Switch platform for UniFi 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. +Support for controlling deep packet inspection (DPI) restriction groups. +""" import logging from typing import Any diff --git a/homeassistant/components/unifi/translations/de.json b/homeassistant/components/unifi/translations/de.json index be38ddf1a4d..05dd66fe56c 100644 --- a/homeassistant/components/unifi/translations/de.json +++ b/homeassistant/components/unifi/translations/de.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Controller-Site ist bereits konfiguriert" + "already_configured": "Controller-Site ist bereits konfiguriert", + "configuration_updated": "Konfiguration aktualisiert.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "faulty_credentials": "Ung\u00fcltige Authentifizierung", diff --git a/homeassistant/components/unifi/translations/es.json b/homeassistant/components/unifi/translations/es.json index a676d70e88c..a5963d7019e 100644 --- a/homeassistant/components/unifi/translations/es.json +++ b/homeassistant/components/unifi/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "El sitio del controlador ya est\u00e1 configurado", + "configuration_updated": "Configuraci\u00f3n actualizada.", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { diff --git a/homeassistant/components/unifi/translations/fr.json b/homeassistant/components/unifi/translations/fr.json index 6e5412ba3d2..d750fb0cdd9 100644 --- a/homeassistant/components/unifi/translations/fr.json +++ b/homeassistant/components/unifi/translations/fr.json @@ -1,13 +1,16 @@ { "config": { "abort": { - "already_configured": "Le contr\u00f4leur est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le contr\u00f4leur est d\u00e9j\u00e0 configur\u00e9", + "configuration_updated": "Configuration mise \u00e0 jour.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "faulty_credentials": "Authentification invalide", "service_unavailable": "\u00c9chec de connexion", "unknown_client_mac": "Aucun client disponible sur cette adresse MAC" }, + "flow_title": "UniFi Network {site} ( {host} )", "step": { "user": { "data": { diff --git a/homeassistant/components/unifi/translations/it.json b/homeassistant/components/unifi/translations/it.json index d50018227c5..f5311f538c1 100644 --- a/homeassistant/components/unifi/translations/it.json +++ b/homeassistant/components/unifi/translations/it.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Il sito del Controller \u00e8 gi\u00e0 configurato", "configuration_updated": "Configurazione aggiornata.", - "reauth_successful": "La riautenticazione ha avuto successo" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "faulty_credentials": "Autenticazione non valida", diff --git a/homeassistant/components/unifi/translations/ko.json b/homeassistant/components/unifi/translations/ko.json index 94160829bad..454feec0922 100644 --- a/homeassistant/components/unifi/translations/ko.json +++ b/homeassistant/components/unifi/translations/ko.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\ucee8\ud2b8\ub864\ub7ec \uc0ac\uc774\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\ucee8\ud2b8\ub864\ub7ec \uc0ac\uc774\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "faulty_credentials": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", @@ -16,7 +17,7 @@ "port": "\ud3ec\ud2b8", "site": "\uc0ac\uc774\ud2b8 ID", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", - "verify_ssl": "\uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\ub294 \ucee8\ud2b8\ub864\ub7ec" + "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "title": "UniFi \ucee8\ud2b8\ub864\ub7ec \uc124\uc815\ud558\uae30" } diff --git a/homeassistant/components/unifi/translations/nl.json b/homeassistant/components/unifi/translations/nl.json index 4e9aa16a245..7f0baf4a3be 100644 --- a/homeassistant/components/unifi/translations/nl.json +++ b/homeassistant/components/unifi/translations/nl.json @@ -1,13 +1,16 @@ { "config": { "abort": { - "already_configured": "Controller site is al geconfigureerd" + "already_configured": "Controller site is al geconfigureerd", + "configuration_updated": "Configuratie bijgewerkt.", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "faulty_credentials": "Foutieve gebruikersgegevens", "service_unavailable": "Geen service beschikbaar", "unknown_client_mac": "Geen client beschikbaar op dat MAC-adres" }, + "flow_title": "UniFi Netwerk {site} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/unifi/translations/pl.json b/homeassistant/components/unifi/translations/pl.json index 6c8c74e726a..719120eeb5e 100644 --- a/homeassistant/components/unifi/translations/pl.json +++ b/homeassistant/components/unifi/translations/pl.json @@ -70,7 +70,7 @@ "allow_uptime_sensors": "Sensory czasu pracy dla klient\u00f3w sieciowych" }, "description": "Konfiguracja sensora statystyk", - "title": "Opcje UniFi" + "title": "Opcje UniFi 3/3" } } } diff --git a/homeassistant/components/unifi/translations/ru.json b/homeassistant/components/unifi/translations/ru.json index 3b69bf0ee33..5810204db41 100644 --- a/homeassistant/components/unifi/translations/ru.json +++ b/homeassistant/components/unifi/translations/ru.json @@ -2,10 +2,11 @@ "config": { "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "configuration_updated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", "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": { - "faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "faulty_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "service_unavailable": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "unknown_client_mac": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043d\u0430 \u044d\u0442\u043e\u043c MAC-\u0430\u0434\u0440\u0435\u0441\u0435." }, diff --git a/homeassistant/components/unifi/translations/sv.json b/homeassistant/components/unifi/translations/sv.json index eb4e0d8ee3d..2e4851e70ed 100644 --- a/homeassistant/components/unifi/translations/sv.json +++ b/homeassistant/components/unifi/translations/sv.json @@ -24,13 +24,17 @@ }, "options": { "step": { + "client_control": { + "title": "UniFi-inst\u00e4llningar 2/3" + }, "device_tracker": { "data": { "detection_time": "Tid i sekunder fr\u00e5n senast sett tills den anses borta", "track_clients": "Sp\u00e5ra n\u00e4tverksklienter", "track_devices": "Sp\u00e5ra n\u00e4tverksenheter (Ubiquiti-enheter)", "track_wired_clients": "Inkludera tr\u00e5dbundna n\u00e4tverksklienter" - } + }, + "title": "UniFi-inst\u00e4llningar 1/3" }, "init": { "data": { @@ -44,7 +48,8 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Skapa bandbreddsanv\u00e4ndningssensorer f\u00f6r n\u00e4tverksklienter" - } + }, + "title": "UniFi-inst\u00e4llningar 2/3" } } } diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py index 904348f6324..03c63ce4e84 100644 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ b/homeassistant/components/unifi/unifi_entity_base.py @@ -91,7 +91,7 @@ class UniFiBase(Entity): entity_registry = await self.hass.helpers.entity_registry.async_get_registry() entity_entry = entity_registry.async_get(self.entity_id) if not entity_entry: - await self.async_remove() + await self.async_remove(force_remove=True) return device_registry = await self.hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 8fff0e80dfb..1834d22855c 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -1,9 +1,14 @@ """Combination of multiple media players for a universal controller.""" from copy import copy +from typing import Optional import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import ( + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( ATTR_APP_ID, ATTR_APP_NAME, @@ -20,6 +25,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, @@ -28,13 +34,23 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_REPEAT_SET, + SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, @@ -45,6 +61,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, + CONF_DEVICE_CLASS, CONF_NAME, CONF_STATE, CONF_STATE_TEMPLATE, @@ -55,7 +72,9 @@ from homeassistant.const import ( SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET, + SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -75,13 +94,10 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.service import async_call_from_config ATTR_ACTIVE_CHILD = "active_child" -ATTR_DATA = "data" CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" -CONF_SERVICE = "service" -CONF_SERVICE_DATA = "service_data" OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] @@ -96,6 +112,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_ATTRS, default={}): vol.Or( cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA ), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_TEMPLATE): cv.template, }, extra=vol.REMOVE_EXTRA, @@ -104,7 +121,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the universal media players.""" - await async_setup_reload_service(hass, "universal", ["media_player"]) player = UniversalMediaPlayer( @@ -113,6 +129,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= config.get(CONF_CHILDREN), config.get(CONF_COMMANDS), config.get(CONF_ATTRS), + config.get(CONF_DEVICE_CLASS), config.get(CONF_STATE_TEMPLATE), ) @@ -122,7 +139,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class UniversalMediaPlayer(MediaPlayerEntity): """Representation of an universal media player.""" - def __init__(self, hass, name, children, commands, attributes, state_template=None): + def __init__( + self, + hass, + name, + children, + commands, + attributes, + device_class=None, + state_template=None, + ): """Initialize the Universal media device.""" self.hass = hass self._name = name @@ -137,6 +163,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): self._child_state = None self._state_template_result = None self._state_template = state_template + self._device_class = device_class async def async_added_to_hass(self): """Subscribe to children and template state changes.""" @@ -242,6 +269,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): """No polling needed.""" return False + @property + def device_class(self) -> Optional[str]: + """Return the class of this device.""" + return self._device_class + @property def master_state(self): """Return the master state for entity or None.""" @@ -382,6 +414,16 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Name of the current running app.""" return self._child_attr(ATTR_APP_NAME) + @property + def sound_mode(self): + """Return the current sound mode of the device.""" + return self._override_or_child_attr(ATTR_SOUND_MODE) + + @property + def sound_mode_list(self): + """List of available sound modes.""" + return self._override_or_child_attr(ATTR_SOUND_MODE_LIST) + @property def source(self): """Return the current input source of the device.""" @@ -392,6 +434,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): """List of available input sources.""" return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST) + @property + def repeat(self): + """Boolean if repeating is enabled.""" + return self._override_or_child_attr(ATTR_MEDIA_REPEAT) + @property def shuffle(self): """Boolean if shuffling is enabled.""" @@ -407,6 +454,22 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_TURN_OFF in self._cmds: flags |= SUPPORT_TURN_OFF + if SERVICE_MEDIA_PLAY_PAUSE in self._cmds: + flags |= SUPPORT_PLAY | SUPPORT_PAUSE + else: + if SERVICE_MEDIA_PLAY in self._cmds: + flags |= SUPPORT_PLAY + if SERVICE_MEDIA_PAUSE in self._cmds: + flags |= SUPPORT_PAUSE + + if SERVICE_MEDIA_STOP in self._cmds: + flags |= SUPPORT_STOP + + if SERVICE_MEDIA_NEXT_TRACK in self._cmds: + flags |= SUPPORT_NEXT_TRACK + if SERVICE_MEDIA_PREVIOUS_TRACK in self._cmds: + flags |= SUPPORT_PREVIOUS_TRACK + if any([cmd in self._cmds for cmd in [SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN]]): flags |= SUPPORT_VOLUME_STEP if SERVICE_VOLUME_SET in self._cmds: @@ -415,7 +478,10 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_VOLUME_MUTE in self._cmds and ATTR_MEDIA_VOLUME_MUTED in self._attrs: flags |= SUPPORT_VOLUME_MUTE - if SERVICE_SELECT_SOURCE in self._cmds: + if ( + SERVICE_SELECT_SOURCE in self._cmds + and ATTR_INPUT_SOURCE_LIST in self._attrs + ): flags |= SUPPORT_SELECT_SOURCE if SERVICE_CLEAR_PLAYLIST in self._cmds: @@ -424,6 +490,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_SHUFFLE_SET in self._cmds and ATTR_MEDIA_SHUFFLE in self._attrs: flags |= SUPPORT_SHUFFLE_SET + if SERVICE_REPEAT_SET in self._cmds and ATTR_MEDIA_REPEAT in self._attrs: + flags |= SUPPORT_REPEAT_SET + + if ( + SERVICE_SELECT_SOUND_MODE in self._cmds + and ATTR_SOUND_MODE_LIST in self._attrs + ): + flags |= SUPPORT_SELECT_SOUND_MODE + return flags @property @@ -502,6 +577,13 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Play or pause the media player.""" await self._async_call_service(SERVICE_MEDIA_PLAY_PAUSE) + async def async_select_sound_mode(self, sound_mode): + """Select sound mode.""" + data = {ATTR_SOUND_MODE: sound_mode} + await self._async_call_service( + SERVICE_SELECT_SOUND_MODE, data, allow_override=True + ) + async def async_select_source(self, source): """Set the input source.""" data = {ATTR_INPUT_SOURCE: source} @@ -516,6 +598,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): data = {ATTR_MEDIA_SHUFFLE: shuffle} await self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) + async def async_set_repeat(self, repeat): + """Set repeat mode.""" + data = {ATTR_MEDIA_REPEAT: repeat} + await self._async_call_service(SERVICE_REPEAT_SET, data, allow_override=True) + + async def async_toggle(self): + """Toggle the power on the media player.""" + await self._async_call_service(SERVICE_TOGGLE) + async def async_update(self): """Update state in HA.""" for child_name in self._children: diff --git a/homeassistant/components/universal/services.yaml b/homeassistant/components/universal/services.yaml index ed8f550275e..8b515151fd9 100644 --- a/homeassistant/components/universal/services.yaml +++ b/homeassistant/components/universal/services.yaml @@ -1,2 +1,2 @@ reload: - description: Reload all universal entities. + description: Reload all universal entities diff --git a/homeassistant/components/upb/translations/ko.json b/homeassistant/components/upb/translations/ko.json index 48cc545d87b..da357f7a136 100644 --- a/homeassistant/components/upb/translations/ko.json +++ b/homeassistant/components/upb/translations/ko.json @@ -1,9 +1,12 @@ { "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "error": { - "cannot_connect": "UPB PIM \uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "invalid_upb_file": "UPB UPStart \ub0b4\ubcf4\ub0b4\uae30 \ud30c\uc77c\uc774 \uc5c6\uac70\ub098 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud30c\uc77c \uc774\ub984\uacfc \uacbd\ub85c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/upcloud/translations/ko.json b/homeassistant/components/upcloud/translations/ko.json new file mode 100644 index 00000000000..04360a9a8f7 --- /dev/null +++ b/homeassistant/components/upcloud/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upcloud/translations/nl.json b/homeassistant/components/upcloud/translations/nl.json index 783032a1da0..312b117208c 100644 --- a/homeassistant/components/upcloud/translations/nl.json +++ b/homeassistant/components/upcloud/translations/nl.json @@ -1,12 +1,14 @@ { "config": { "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie" }, "step": { "user": { "data": { - "password": "Wachtwoord" + "password": "Wachtwoord", + "username": "Gebruikersnaam" } } } diff --git a/homeassistant/components/upcloud/translations/ru.json b/homeassistant/components/upcloud/translations/ru.json index ced4097a7e2..c64a69965b2 100644 --- a/homeassistant/components/upcloud/translations/ru.json +++ b/homeassistant/components/upcloud/translations/ru.json @@ -2,7 +2,7 @@ "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.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 13497da8290..9d65bb4c5d4 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -1,10 +1,10 @@ """Support to check for available updates.""" import asyncio from datetime import timedelta -from distutils.version import StrictVersion import logging import async_timeout +from awesomeversion import AwesomeVersion from distro import linux_distribution # pylint: disable=import-error import voluptuous as vol @@ -83,16 +83,16 @@ async def async_setup(hass, config): # Validate version update_available = False - if StrictVersion(newest) > StrictVersion(current_version): + if AwesomeVersion(newest) > AwesomeVersion(current_version): _LOGGER.debug( "The latest available version of Home Assistant is %s", newest ) update_available = True - elif StrictVersion(newest) == StrictVersion(current_version): + elif AwesomeVersion(newest) == AwesomeVersion(current_version): _LOGGER.debug( "You are on the latest version (%s) of Home Assistant", newest ) - elif StrictVersion(newest) < StrictVersion(current_version): + elif AwesomeVersion(newest) < AwesomeVersion(current_version): _LOGGER.debug( "Local version (%s) is newer than the latest available version (%s)", current_version, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index c9f96a0e9d7..d5be0757cf3 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,7 +1,6 @@ """Open ports in your router for Home Assistant and provide statistics.""" import asyncio from ipaddress import ip_address -from operator import itemgetter import voluptuous as vol @@ -14,12 +13,12 @@ from homeassistant.util import get_local_ip from .const import ( CONF_LOCAL_IP, + CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DISCOVERY_LOCATION, DISCOVERY_ST, DISCOVERY_UDN, - DISCOVERY_USN, DOMAIN, DOMAIN_CONFIG, DOMAIN_COORDINATORS, @@ -33,51 +32,38 @@ NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string)})}, + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), + }, + ) + }, extra=vol.ALLOW_EXTRA, ) -async def async_discover_and_construct( - hass: HomeAssistantType, udn: str = None, st: str = None -) -> Device: +async def async_construct_device(hass: HomeAssistantType, udn: str, st: str) -> Device: """Discovery devices and construct a Device for one.""" # pylint: disable=invalid-name _LOGGER.debug("Constructing device: %s::%s", udn, st) - discovery_infos = await Device.async_discover(hass) - _LOGGER.debug("Discovered devices: %s", discovery_infos) - if not discovery_infos: - _LOGGER.info("No UPnP/IGD devices discovered") + discoveries = [ + discovery + for discovery in await Device.async_discover(hass) + if discovery[DISCOVERY_UDN] == udn and discovery[DISCOVERY_ST] == st + ] + if not discoveries: + _LOGGER.info("Device not discovered") return None - if udn: - # Get the discovery info with specified UDN/ST. - filtered = [di for di in discovery_infos if di[DISCOVERY_UDN] == udn] - if st: - filtered = [di for di in filtered if di[DISCOVERY_ST] == st] - if not filtered: - _LOGGER.warning( - 'Wanted UPnP/IGD device with UDN/ST "%s"/"%s" not found, aborting', - udn, - st, - ) - return None + # Some additional clues for remote debugging. + if len(discoveries) > 1: + _LOGGER.info("Multiple devices discovered: %s", discoveries) - # Ensure we're always taking the latest, if we filtered only on UDN. - filtered = sorted(filtered, key=itemgetter(DISCOVERY_ST), reverse=True) - discovery_info = filtered[0] - else: - # Get the first/any. - discovery_info = discovery_infos[0] - if len(discovery_infos) > 1: - device_name = discovery_info.get( - DISCOVERY_USN, discovery_info.get(DISCOVERY_LOCATION, "") - ) - _LOGGER.info("Detected multiple UPnP/IGD devices, using: %s", device_name) - - _LOGGER.debug("Constructing from discovery_info: %s", discovery_info) - location = discovery_info[DISCOVERY_LOCATION] + discovery = discoveries[0] + _LOGGER.debug("Constructing from discovery: %s", discovery) + location = discovery[DISCOVERY_LOCATION] return await Device.async_create_device(hass, location) @@ -110,10 +96,10 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) # Discover and construct. - udn = config_entry.data.get(CONFIG_ENTRY_UDN) - st = config_entry.data.get(CONFIG_ENTRY_ST) # pylint: disable=invalid-name + udn = config_entry.data[CONFIG_ENTRY_UDN] + st = config_entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name try: - device = await async_discover_and_construct(hass, udn, st) + device = await async_construct_device(hass, udn, st) except asyncio.TimeoutError as err: raise ConfigEntryNotReady from err @@ -136,6 +122,16 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) unique_id=device.unique_id, ) + # Ensure entry has a hostname, for older entries. + if ( + CONFIG_ENTRY_HOSTNAME not in config_entry.data + or config_entry.data[CONFIG_ENTRY_HOSTNAME] != device.hostname + ): + hass.config_entries.async_update_entry( + entry=config_entry, + data={CONFIG_ENTRY_HOSTNAME: device.hostname, **config_entry.data}, + ) + # Create device registry entry. device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 7b20c7709a0..e1101c3713c 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,6 +1,6 @@ """Config flow for UPNP.""" from datetime import timedelta -from typing import Mapping, Optional +from typing import Any, Mapping, Optional import voluptuous as vol @@ -9,15 +9,18 @@ from homeassistant.components import ssdp from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import callback -from .const import ( # pylint: disable=unused-import +from .const import ( + CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, + DISCOVERY_HOSTNAME, DISCOVERY_LOCATION, DISCOVERY_NAME, DISCOVERY_ST, DISCOVERY_UDN, + DISCOVERY_UNIQUE_ID, DISCOVERY_USN, DOMAIN, DOMAIN_COORDINATORS, @@ -26,6 +29,16 @@ from .const import ( # pylint: disable=unused-import from .device import Device +def discovery_info_to_discovery(discovery_info: Mapping) -> Mapping: + """Convert a SSDP-discovery to 'our' discovery.""" + return { + DISCOVERY_UDN: discovery_info[ssdp.ATTR_UPNP_UDN], + DISCOVERY_ST: discovery_info[ssdp.ATTR_SSDP_ST], + DISCOVERY_LOCATION: discovery_info[ssdp.ATTR_SSDP_LOCATION], + DISCOVERY_USN: discovery_info[ssdp.ATTR_SSDP_USN], + } + + class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a UPnP/IGD config flow.""" @@ -37,43 +50,46 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # - user(None): scan --> user({...}) --> create_entry() # - import(None) --> create_entry() - def __init__(self): + def __init__(self) -> None: """Initialize the UPnP/IGD config flow.""" self._discoveries: Mapping = None - async def async_step_user(self, user_input: Optional[Mapping] = None): + async def async_step_user( + self, user_input: Optional[Mapping] = None + ) -> Mapping[str, Any]: """Handle a flow start.""" _LOGGER.debug("async_step_user: user_input: %s", user_input) - # This uses DISCOVERY_USN as the identifier for the device. if user_input is not None: # Ensure wanted device was discovered. matching_discoveries = [ discovery for discovery in self._discoveries - if discovery[DISCOVERY_USN] == user_input["usn"] + if discovery[DISCOVERY_UNIQUE_ID] == 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[DISCOVERY_USN], raise_on_progress=False + discovery[DISCOVERY_UNIQUE_ID], raise_on_progress=False ) return await self._async_create_entry_from_discovery(discovery) # Discover devices. - discoveries = await Device.async_discover(self.hass) + discoveries = [ + await Device.async_supplement_discovery(self.hass, discovery) + for discovery in await Device.async_discover(self.hass) + ] - # Store discoveries which have not been configured, add name for each discovery. - current_usns = {entry.unique_id for entry in self._async_current_entries()} + # Store discoveries which have not been configured. + current_unique_ids = { + entry.unique_id for entry in self._async_current_entries() + } self._discoveries = [ - { - **discovery, - DISCOVERY_NAME: await self._async_get_name_for_discovery(discovery), - } + discovery for discovery in discoveries - if discovery[DISCOVERY_USN] not in current_usns + if discovery[DISCOVERY_UNIQUE_ID] not in current_unique_ids ] # Ensure anything to add. @@ -82,9 +98,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema = vol.Schema( { - vol.Required("usn"): vol.In( + vol.Required("unique_id"): vol.In( { - discovery[DISCOVERY_USN]: discovery[DISCOVERY_NAME] + discovery[DISCOVERY_UNIQUE_ID]: discovery[DISCOVERY_NAME] for discovery in self._discoveries } ), @@ -95,7 +111,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, ) - async def async_step_import(self, import_info: Optional[Mapping]): + async def async_step_import( + self, import_info: Optional[Mapping] + ) -> Mapping[str, Any]: """Import a new UPnP/IGD device as a config entry. This flow is triggered by `async_setup`. If no device has been @@ -119,18 +137,24 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_devices_found") # Ensure complete discovery. - discovery_info = self._discoveries[0] - if DISCOVERY_USN not in discovery_info: + discovery = self._discoveries[0] + if ( + DISCOVERY_UDN not in discovery + or DISCOVERY_ST not in discovery + or DISCOVERY_LOCATION not in discovery + or DISCOVERY_USN not in discovery + ): _LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. - usn = discovery_info[DISCOVERY_USN] - await self.async_set_unique_id(usn) + discovery = await Device.async_supplement_discovery(self.hass, discovery) + unique_id = discovery[DISCOVERY_UNIQUE_ID] + await self.async_set_unique_id(unique_id) - return await self._async_create_entry_from_discovery(discovery_info) + return await self._async_create_entry_from_discovery(discovery) - async def async_step_ssdp(self, discovery_info: Mapping): + async def async_step_ssdp(self, discovery_info: Mapping) -> Mapping[str, Any]: """Handle a discovered UPnP/IGD device. This flow is triggered by the SSDP component. It will check if the @@ -142,36 +166,46 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if ( ssdp.ATTR_UPNP_UDN not in discovery_info or ssdp.ATTR_SSDP_ST not in discovery_info + or ssdp.ATTR_SSDP_LOCATION not in discovery_info + or ssdp.ATTR_SSDP_USN not in discovery_info ): _LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") + # Convert to something we understand/speak. + discovery = discovery_info_to_discovery(discovery_info) + # Ensure not already configuring/configured. - udn = discovery_info[ssdp.ATTR_UPNP_UDN] - st = discovery_info[ssdp.ATTR_SSDP_ST] # pylint: disable=invalid-name - usn = f"{udn}::{st}" - await self.async_set_unique_id(usn) - self._abort_if_unique_id_configured() + discovery = await Device.async_supplement_discovery(self.hass, discovery) + unique_id = discovery[DISCOVERY_UNIQUE_ID] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} + ) + + # Handle devices changing their UDN, only allow a single + existing_entries = self.hass.config_entries.async_entries(DOMAIN) + for config_entry in existing_entries: + entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) + if entry_hostname == discovery[DISCOVERY_HOSTNAME]: + _LOGGER.debug( + "Found existing config_entry with same hostname, discovery ignored" + ) + return self.async_abort(reason="discovery_ignored") # Store discovery. - _LOGGER.debug("New discovery, continuing") - name = discovery_info.get("friendlyName", "") - discovery = { - DISCOVERY_UDN: udn, - DISCOVERY_ST: st, - DISCOVERY_NAME: name, - } self._discoveries = [discovery] # Ensure user recognizable. - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { - "name": name, + "name": discovery[DISCOVERY_NAME], } return await self.async_step_ssdp_confirm() - async def async_step_ssdp_confirm(self, user_input: Optional[Mapping] = None): + async def async_step_ssdp_confirm( + self, user_input: Optional[Mapping] = None + ) -> Mapping[str, Any]: """Confirm integration via SSDP.""" _LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) if user_input is None: @@ -182,52 +216,42 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Define the config flow to handle options.""" return UpnpOptionsFlowHandler(config_entry) async def _async_create_entry_from_discovery( self, discovery: Mapping, - ): + ) -> Mapping[str, Any]: """Create an entry from discovery.""" _LOGGER.debug( "_async_create_entry_from_discovery: discovery: %s", discovery, ) - # Get name from device, if not found already. - if DISCOVERY_NAME not in discovery and DISCOVERY_LOCATION in discovery: - discovery[DISCOVERY_NAME] = await self._async_get_name_for_discovery( - discovery - ) title = discovery.get(DISCOVERY_NAME, "") data = { CONFIG_ENTRY_UDN: discovery[DISCOVERY_UDN], CONFIG_ENTRY_ST: discovery[DISCOVERY_ST], + CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME], } return self.async_create_entry(title=title, data=data) - async def _async_get_name_for_discovery(self, discovery: Mapping): - """Get the name of the device from a discovery.""" - _LOGGER.debug("_async_get_name_for_discovery: discovery: %s", discovery) - device = await Device.async_create_device( - self.hass, discovery[DISCOVERY_LOCATION] - ) - return device.name - class UpnpOptionsFlowHandler(config_entries.OptionsFlow): """Handle a UPnP options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: Mapping = None) -> None: """Manage the options.""" if user_input is not None: - udn = self.config_entry.data.get(CONFIG_ENTRY_UDN) + udn = self.config_entry.data[CONFIG_ENTRY_UDN] coordinator = self.hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] update_interval_sec = user_input.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 8256fdd9fc9..6575139c4a4 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -8,10 +8,10 @@ LOGGER = logging.getLogger(__package__) CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" +DOMAIN_CONFIG = "config" DOMAIN_COORDINATORS = "coordinators" DOMAIN_DEVICES = "devices" DOMAIN_LOCAL_IP = "local_ip" -DOMAIN_CONFIG = "config" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" @@ -21,12 +21,15 @@ DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) -DISCOVERY_NAME = "name" +DISCOVERY_HOSTNAME = "hostname" DISCOVERY_LOCATION = "location" +DISCOVERY_NAME = "name" DISCOVERY_ST = "st" DISCOVERY_UDN = "udn" +DISCOVERY_UNIQUE_ID = "unique_id" DISCOVERY_USN = "usn" -CONFIG_ENTRY_UDN = "udn" -CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" +CONFIG_ENTRY_ST = "st" +CONFIG_ENTRY_UDN = "udn" +CONFIG_ENTRY_HOSTNAME = "hostname" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).seconds diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 6bc497170ca..a06ca254c87 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,7 +1,10 @@ """Home Assistant representation of an UPnP/IGD.""" +from __future__ import annotations + import asyncio from ipaddress import IPv4Address from typing import List, Mapping +from urllib.parse import urlparse from async_upnp_client import UpnpFactory from async_upnp_client.aiohttp import AiohttpSessionRequester @@ -15,9 +18,12 @@ from .const import ( BYTES_RECEIVED, BYTES_SENT, CONF_LOCAL_IP, + DISCOVERY_HOSTNAME, DISCOVERY_LOCATION, + DISCOVERY_NAME, DISCOVERY_ST, DISCOVERY_UDN, + DISCOVERY_UNIQUE_ID, DISCOVERY_USN, DOMAIN, DOMAIN_CONFIG, @@ -29,12 +35,11 @@ from .const import ( class Device: - """Home Assistant representation of an UPnP/IGD.""" + """Home Assistant representation of a UPnP/IGD device.""" def __init__(self, igd_device): """Initialize UPnP/IGD device.""" self._igd_device: IgdDevice = igd_device - self._mapped_ports = [] @classmethod async def async_discover(cls, hass: HomeAssistantType) -> List[Mapping]: @@ -46,24 +51,35 @@ class Device: if local_ip: local_ip = IPv4Address(local_ip) - discovery_infos = await IgdDevice.async_search(source_ip=local_ip, timeout=10) + discoveries = await IgdDevice.async_search(source_ip=local_ip, timeout=10) - # add extra info and store devices - devices = [] - for discovery_info in discovery_infos: - discovery_info[DISCOVERY_UDN] = discovery_info["_udn"] - discovery_info[DISCOVERY_ST] = discovery_info["st"] - discovery_info[DISCOVERY_LOCATION] = discovery_info["location"] - usn = f"{discovery_info[DISCOVERY_UDN]}::{discovery_info[DISCOVERY_ST]}" - discovery_info[DISCOVERY_USN] = usn - _LOGGER.debug("Discovered device: %s", discovery_info) + # Supplement/standardize discovery. + for discovery in discoveries: + discovery[DISCOVERY_UDN] = discovery["_udn"] + discovery[DISCOVERY_ST] = discovery["st"] + discovery[DISCOVERY_LOCATION] = discovery["location"] + discovery[DISCOVERY_USN] = discovery["usn"] + _LOGGER.debug("Discovered device: %s", discovery) - devices.append(discovery_info) - - return devices + return discoveries @classmethod - async def async_create_device(cls, hass: HomeAssistantType, ssdp_location: str): + async def async_supplement_discovery( + cls, hass: HomeAssistantType, discovery: Mapping + ) -> Mapping: + """Get additional data from device and supplement discovery.""" + location = discovery[DISCOVERY_LOCATION] + device = await Device.async_create_device(hass, location) + discovery[DISCOVERY_NAME] = device.name + discovery[DISCOVERY_HOSTNAME] = device.hostname + discovery[DISCOVERY_UNIQUE_ID] = discovery[DISCOVERY_USN] + + return discovery + + @classmethod + async def async_create_device( + cls, hass: HomeAssistantType, ssdp_location: str + ) -> Device: """Create UPnP/IGD device.""" # build async_upnp_client requester session = async_get_clientsession(hass) @@ -102,10 +118,22 @@ class Device: """Get the device type.""" return self._igd_device.device_type + @property + def usn(self) -> str: + """Get the USN.""" + return f"{self.udn}::{self.device_type}" + @property def unique_id(self) -> str: """Get the unique id.""" - return f"{self.udn}::{self.device_type}" + return self.usn + + @property + def hostname(self) -> str: + """Get the hostname.""" + url = self._igd_device.device.device_url + parsed = urlparse(url) + return parsed.hostname def __str__(self) -> str: """Get string representation.""" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index a9906e535b9..97d3c1a702c 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,6 +1,6 @@ """Support for UPnP/IGD Sensors.""" from datetime import timedelta -from typing import Any, Mapping +from typing import Any, Mapping, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND @@ -83,13 +83,7 @@ async def async_setup_entry( hass, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the UPnP/IGD sensors.""" - data = config_entry.data - if CONFIG_ENTRY_UDN in data: - udn = data[CONFIG_ENTRY_UDN] - else: - # any device will do - udn = list(hass.data[DOMAIN][DOMAIN_DEVICES])[0] - + udn = config_entry.data[CONFIG_ENTRY_UDN] device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] update_interval_sec = config_entry.options.get( @@ -182,7 +176,7 @@ class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @property - def state(self) -> str: + def state(self) -> Optional[str]: """Return the state of the device.""" device_value_key = self._sensor_type["device_value_key"] value = self.coordinator.data[device_value_key] @@ -220,7 +214,7 @@ class DerivedUpnpSensor(UpnpSensor): return current_value < self._last_value @property - def state(self) -> str: + def state(self) -> Optional[str]: """Return the state of the device.""" # Can't calculate any derivative if we have only one value. device_value_key = self._sensor_type["device_value_key"] diff --git a/homeassistant/components/upnp/translations/ko.json b/homeassistant/components/upnp/translations/ko.json index 1a7b5204930..7dd2e5c685c 100644 --- a/homeassistant/components/upnp/translations/ko.json +++ b/homeassistant/components/upnp/translations/ko.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "UPnP/IGD \uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "incomplete_discovery": "\uae30\uae30 \uac80\uc0c9\uc774 \uc644\uc804\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", - "no_devices_found": "UPnP/IGD \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" }, "flow_title": "UPnP/IGD: {name}", "step": { diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 40a544a2e21..9fd98de42df 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_TIME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, @@ -30,7 +31,6 @@ ATTR_EXTERNAL_ID = "external_id" ATTR_MAGNITUDE = "magnitude" ATTR_PLACE = "place" ATTR_STATUS = "status" -ATTR_TIME = "time" ATTR_TYPE = "type" ATTR_UPDATED = "updated" @@ -210,7 +210,7 @@ class UsgsEarthquakesEvent(GeolocationEvent): """Remove this entity.""" self._remove_signal_delete() self._remove_signal_update() - self.hass.async_create_task(self.async_remove()) + self.hass.async_create_task(self.async_remove(force_remove=True)) @callback def _update_callback(self): diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 4f5cfa3907e..a20b99d673a 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -2,13 +2,14 @@ from datetime import datetime import logging import re +from typing import Optional import requests from uvcclient import camera as uvc_camera, nvr import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera -from homeassistant.const import CONF_PORT, CONF_SSL +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -16,7 +17,6 @@ _LOGGER = logging.getLogger(__name__) CONF_NVR = "nvr" CONF_KEY = "key" -CONF_PASSWORD = "password" DEFAULT_PASSWORD = "ubnt" DEFAULT_PORT = 7080 @@ -111,9 +111,9 @@ class UnifiVideoCamera(Camera): return 0 @property - def state_attributes(self): + def device_state_attributes(self): """Return the camera state attributes.""" - attr = super().state_attributes + attr = {} if self.motion_detection_enabled: attr["last_recording_start_time"] = timestamp_ms_to_date( self._caminfo["lastRecordingStartTime"] @@ -124,7 +124,7 @@ class UnifiVideoCamera(Camera): def is_recording(self): """Return true if the camera is recording.""" recording_state = "DISABLED" - if "recordingIndicator" in self._caminfo.keys(): + if "recordingIndicator" in self._caminfo: recording_state = self._caminfo["recordingIndicator"] return ( @@ -196,7 +196,6 @@ class UnifiVideoCamera(Camera): def camera_image(self): """Return the image of this camera.""" - if not self._camera: if not self._login(): return @@ -256,7 +255,8 @@ class UnifiVideoCamera(Camera): self._caminfo = self._nvr.get_camera(self._uuid) -def timestamp_ms_to_date(epoch_ms) -> datetime or None: +def timestamp_ms_to_date(epoch_ms: int) -> Optional[datetime]: """Convert millisecond timestamp to datetime.""" if epoch_ms: return datetime.fromtimestamp(epoch_ms / 1000) + return None diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 29fc5628b22..21a2ae5e8c2 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -17,7 +17,7 @@ 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, STATE_CLEANING, STATE_DOCKED, STATES +from . import DOMAIN, STATE_CLEANING, STATE_DOCKED TRIGGER_TYPES = {"cleaning", "docked"} @@ -71,16 +71,13 @@ async def async_attach_trigger( config = TRIGGER_SCHEMA(config) if config[CONF_TYPE] == "cleaning": - from_state = [state for state in STATES if state != STATE_CLEANING] to_state = STATE_CLEANING else: - from_state = [state for state in STATES if state != STATE_DOCKED] to_state = STATE_DOCKED state_config = { CONF_PLATFORM: "state", CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state_trigger.CONF_FROM: from_state, state_trigger.CONF_TO: to_state, } state_config = state_trigger.TRIGGER_SCHEMA(state_config) diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 3287eafe7f2..e0064bc475b 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -1,87 +1,80 @@ # Describes the format for available vacuum services turn_on: + name: Turn on description: Start a new cleaning task. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: turn_off: + name: Turn off description: Stop the current cleaning task and return to home. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: stop: + name: Stop description: Stop the current cleaning task. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: locate: + name: Locate description: Locate the vacuum cleaner robot. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: start_pause: + name: Start/Pause description: Start, pause, or resume the cleaning task. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: start: + name: Start description: Start or resume the cleaning task. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: pause: + name: Pause description: Pause the cleaning task. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: return_to_base: + name: Return to base description: Tell the vacuum cleaner to return to its dock. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: clean_spot: + name: Clean spot description: Tell the vacuum cleaner to do a spot clean-up. - fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" + target: send_command: + name: Send command description: Send a raw command to the vacuum cleaner. + target: fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" command: + name: Command description: Command to execute. + required: true example: "set_dnd_timer" + selector: + text: params: + name: Parameters description: Parameters for the command. example: '{ "key": "value" }' + selector: + object: set_fan_speed: + name: Set fan speed description: Set the fan speed of the vacuum cleaner. + target: fields: - entity_id: - description: Name of the vacuum entity. - example: "vacuum.xiaomi_vacuum_cleaner" fan_speed: - description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium' or by percentage, between 0 and 100. + name: Fan speed + description: + Platform dependent vacuum cleaner fan speed, with speed steps, like + 'medium' or by percentage, between 0 and 100. + required: true example: "low" + selector: + text: diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index c79ee15db59..525bf00f50e 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -137,7 +137,20 @@ class ValloxFan(FanEntity): self._available = False _LOGGER.error("Error updating fan: %s", err) - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" _LOGGER.debug("Turn on: %s", speed) diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 8b1609be6ba..882274f8e84 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -6,7 +6,7 @@ import vasttrafik import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_DELAY, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -20,7 +20,6 @@ ATTR_LINE = "line" ATTR_TRACK = "track" ATTRIBUTION = "Data provided by Västtrafik" -CONF_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" CONF_HEADING = "heading" @@ -55,7 +54,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the departure sensor.""" - planner = vasttrafik.JournyPlanner(config.get(CONF_KEY), config.get(CONF_SECRET)) sensors = [] diff --git a/homeassistant/components/velbus/translations/ko.json b/homeassistant/components/velbus/translations/ko.json index 3d23ff3727d..fa9c4f7496d 100644 --- a/homeassistant/components/velbus/translations/ko.json +++ b/homeassistant/components/velbus/translations/ko.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index bac65c969cf..90ed0a91b14 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -58,11 +58,18 @@ class VeluxModule: _LOGGER.debug("Velux interface terminated") await self.pyvlx.disconnect() + async def async_reboot_gateway(service_call): + await self.pyvlx.reboot_gateway() + self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) host = self._domain_config.get(CONF_HOST) password = self._domain_config.get(CONF_PASSWORD) self.pyvlx = PyVLX(host=host, password=password) + self._hass.services.async_register( + DOMAIN, "reboot_gateway", async_reboot_gateway + ) + async def async_start(self): """Start velux component.""" _LOGGER.debug("Velux interface started") diff --git a/homeassistant/components/velux/services.yaml b/homeassistant/components/velux/services.yaml new file mode 100644 index 00000000000..2460db0bbb0 --- /dev/null +++ b/homeassistant/components/velux/services.yaml @@ -0,0 +1,4 @@ +# Velux Integration services + +reboot_gateway: + description: Reboots the KLF200 Gateway. diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 3fd1c189b63..4bfa72b5eb6 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -232,6 +232,11 @@ class VeraDevice(Generic[DeviceType], Entity): """Update the state.""" self.schedule_update_ha_state(True) + def update(self): + """Force a refresh from the device if the device is unavailable.""" + if not self.available: + self.vera_device.refresh() + @property def name(self) -> str: """Return the name of the device.""" @@ -276,6 +281,11 @@ class VeraDevice(Generic[DeviceType], Entity): return attr + @property + def available(self): + """If device communications have failed return false.""" + return not self.vera_device.comm_failure + @property def unique_id(self) -> str: """Return a unique ID. diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index a84764209b2..00d4fb3a758 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -27,7 +27,8 @@ async def async_setup_entry( [ VeraBinarySensor(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) @@ -49,4 +50,5 @@ class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity) def update(self) -> None: """Get the latest data and update the state.""" + super().update() self._state = self.vera_device.is_tripped diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 70694af012f..9abfc485268 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -44,7 +44,8 @@ async def async_setup_entry( [ VeraThermostat(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index 69e412bdade..43f68fba786 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -28,7 +28,8 @@ async def async_setup_entry( [ VeraCover(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index 7daaf095a5c..30c4e93a2ba 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -32,7 +32,8 @@ async def async_setup_entry( [ VeraLight(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) @@ -92,6 +93,7 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): def update(self) -> None: """Call to update state.""" + super().update() self._state = self.vera_device.is_switched_on() if self.vera_device.is_dimmable: # If it is dimmable, both functions exist. In case color diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 36a5f4cf2f3..b77f17d3b0a 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -31,7 +31,8 @@ async def async_setup_entry( [ VeraLock(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json index 264f44782f5..1f180b39750 100644 --- a/homeassistant/components/vera/manifest.json +++ b/homeassistant/components/vera/manifest.json @@ -3,6 +3,6 @@ "name": "Vera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vera", - "requirements": ["pyvera==0.3.11"], + "requirements": ["pyvera==0.3.13"], "codeowners": ["@vangorra"] } diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index a031aadb66c..2274b67f683 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -21,7 +21,7 @@ async def async_setup_entry( """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) async_add_entities( - [VeraScene(device, controller_data) for device in controller_data.scenes] + [VeraScene(device, controller_data) for device in controller_data.scenes], True ) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index e39b752bbf3..007290807e6 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -28,7 +28,8 @@ async def async_setup_entry( [ VeraSensor(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) @@ -67,7 +68,7 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], Entity): def update(self) -> None: """Update the state.""" - + super().update() if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: self.current_value = self.vera_device.temperature diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json index 844d1777f5d..66958f44a62 100644 --- a/homeassistant/components/vera/strings.json +++ b/homeassistant/components/vera/strings.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "cannot_connect": "Could not connect to controller with url {base_url}" + "cannot_connect": "Could not connect to controller with URL {base_url}" }, "step": { "user": { "title": "Setup Vera controller", - "description": "Provide a Vera controller url below. It should look like this: http://192.168.1.161:3480.", + "description": "Provide a Vera controller URL below. It should look like this: http://192.168.1.161:3480.", "data": { "vera_controller_url": "Controller URL", "lights": "Vera switch device ids to treat as lights in Home Assistant.", diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index d0cbeba669c..f567893e5b0 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -28,7 +28,8 @@ async def async_setup_entry( [ VeraSwitch(device, controller_data) for device in controller_data.devices.get(PLATFORM_DOMAIN) - ] + ], + True, ) @@ -69,4 +70,5 @@ class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): def update(self) -> None: """Update device state.""" + super().update() self._state = self.vera_device.is_switched_on() diff --git a/homeassistant/components/vera/translations/ca.json b/homeassistant/components/vera/translations/ca.json index 63ec236ef89..13a889cb7db 100644 --- a/homeassistant/components/vera/translations/ca.json +++ b/homeassistant/components/vera/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "No s'ha pogut connectar amb el controlador amb l'URL {base_url}" + "cannot_connect": "No s'ha pogut connectar amb el controlador amb URL {base_url}" }, "step": { "user": { @@ -10,7 +10,7 @@ "lights": "Identificadors de dispositiu dels commutadors Vera a tractar com a llums a Home Assistant.", "vera_controller_url": "URL del controlador" }, - "description": "Proporciona un URL pel controlador Vera. Hauria de quedar aix\u00ed: http://192.168.1.161:3480.", + "description": "Proporciona un URL pel controlador Vera. Hauria de ser similar al seg\u00fcent: http://192.168.1.161:3480.", "title": "Configuraci\u00f3 del controlador Vera" } } diff --git a/homeassistant/components/vera/translations/en.json b/homeassistant/components/vera/translations/en.json index 5503d6b1034..94f490d71ee 100644 --- a/homeassistant/components/vera/translations/en.json +++ b/homeassistant/components/vera/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Could not connect to controller with url {base_url}" + "cannot_connect": "Could not connect to controller with URL {base_url}" }, "step": { "user": { @@ -10,7 +10,7 @@ "lights": "Vera switch device ids to treat as lights in Home Assistant.", "vera_controller_url": "Controller URL" }, - "description": "Provide a Vera controller url below. It should look like this: http://192.168.1.161:3480.", + "description": "Provide a Vera controller URL below. It should look like this: http://192.168.1.161:3480.", "title": "Setup Vera controller" } } diff --git a/homeassistant/components/vera/translations/it.json b/homeassistant/components/vera/translations/it.json index e144bf251cd..3ec026beaac 100644 --- a/homeassistant/components/vera/translations/it.json +++ b/homeassistant/components/vera/translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Impossibile connettersi al controllore con l'url {base_url}" + "cannot_connect": "Impossibile connettersi al controller con l'URL {base_url}" }, "step": { "user": { @@ -10,7 +10,7 @@ "lights": "Gli ID dei dispositivi switch Vera da trattare come luci in Home Assistant.", "vera_controller_url": "URL del controller" }, - "description": "Fornire un url di controllo Vera di seguito. Dovrebbe avere questo aspetto: http://192.168.1.161:3480.", + "description": "Fornisci un URL controller Vera di seguito. Dovrebbe assomigliare a questo: http://192.168.1.161:3480.", "title": "Configurazione controller Vera" } } diff --git a/homeassistant/components/vera/translations/ko.json b/homeassistant/components/vera/translations/ko.json index 1556b9fe0d2..e8658528488 100644 --- a/homeassistant/components/vera/translations/ko.json +++ b/homeassistant/components/vera/translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "URL {base_url} \uc5d0 \ucee8\ud2b8\ub864\ub7ec\ub97c \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + "cannot_connect": "{base_url} URL \uc8fc\uc18c\uc758 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" }, "step": { "user": { @@ -10,7 +10,7 @@ "lights": "Vera \uc2a4\uc704\uce58 \uae30\uae30 ID \ub294 Home Assistant \uc5d0\uc11c \uc870\uba85\uc73c\ub85c \ucde8\uae09\ub429\ub2c8\ub2e4.", "vera_controller_url": "\ucee8\ud2b8\ub864\ub7ec URL" }, - "description": "\uc544\ub798\uc5d0 Vera \ucee8\ud2b8\ub864\ub7ec URL \uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. http://192.168.1.161:3480 \uacfc \uac19\uc740 \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "description": "\uc544\ub798\uc5d0 Vera \ucee8\ud2b8\ub864\ub7ec URL \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. http://192.168.1.161:3480 \uacfc \uac19\uc740 \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4.", "title": "Vera \ucee8\ud2b8\ub864\ub7ec \uc124\uc815\ud558\uae30" } } diff --git a/homeassistant/components/vera/translations/no.json b/homeassistant/components/vera/translations/no.json index 7ec6850a7c8..f1454be6799 100644 --- a/homeassistant/components/vera/translations/no.json +++ b/homeassistant/components/vera/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Kunne ikke koble til kontrolleren med url {base_url}" + "cannot_connect": "Kan ikke koble til kontrolleren med URL-adressen {base_url}" }, "step": { "user": { @@ -10,7 +10,7 @@ "lights": "Vera bytter enhets ID-er for \u00e5 behandle som lys i Home Assistant", "vera_controller_url": "URL-adresse for kontroller" }, - "description": "Oppgi en Vera-kontroller-url nedenfor. Det skal se slik ut: http://192.168.1.161:3480.", + "description": "Gi en Vera-kontroller-URL nedenfor. Det skal se slik ut: http://192.168.1.161:3480.", "title": "Oppsett Vera-kontroller" } } diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 22c5e0c2362..814b5f148fa 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -2,6 +2,6 @@ "domain": "verisure", "name": "Verisure", "documentation": "https://www.home-assistant.io/integrations/verisure", - "requirements": ["jsonpath==0.82", "vsure==1.6.1"], + "requirements": ["jsonpath==0.82", "vsure==1.7.2"], "codeowners": ["@frenck"] } diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 94a0d5c2f25..24bd0f000df 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -18,11 +18,12 @@ from .const import ( VS_DISCOVERY, VS_DISPATCHERS, VS_FANS, + VS_LIGHTS, VS_MANAGER, VS_SWITCHES, ) -PLATFORMS = ["switch", "fan"] +PLATFORMS = ["switch", "fan", "light"] _LOGGER = logging.getLogger(__name__) @@ -85,6 +86,7 @@ async def async_setup_entry(hass, config_entry): switches = hass.data[DOMAIN][VS_SWITCHES] = [] fans = hass.data[DOMAIN][VS_FANS] = [] + lights = hass.data[DOMAIN][VS_LIGHTS] = [] hass.data[DOMAIN][VS_DISPATCHERS] = [] @@ -96,15 +98,21 @@ async def async_setup_entry(hass, config_entry): fans.extend(device_dict[VS_FANS]) hass.async_create_task(forward_setup(config_entry, "fan")) + if device_dict[VS_LIGHTS]: + lights.extend(device_dict[VS_LIGHTS]) + hass.async_create_task(forward_setup(config_entry, "light")) + async def async_new_device_discovery(service): """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] switches = hass.data[DOMAIN][VS_SWITCHES] fans = hass.data[DOMAIN][VS_FANS] + lights = hass.data[DOMAIN][VS_LIGHTS] dev_dict = await async_process_devices(hass, manager) switch_devs = dev_dict.get(VS_SWITCHES, []) fan_devs = dev_dict.get(VS_FANS, []) + light_devs = dev_dict.get(VS_LIGHTS, []) switch_set = set(switch_devs) new_switches = list(switch_set.difference(switches)) @@ -126,6 +134,16 @@ async def async_setup_entry(hass, config_entry): fans.extend(new_fans) hass.async_create_task(forward_setup(config_entry, "fan")) + light_set = set(light_devs) + new_lights = list(light_set.difference(lights)) + if new_lights and lights: + lights.extend(new_lights) + async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights) + return + if new_lights and not lights: + lights.extend(new_lights) + hass.async_create_task(forward_setup(config_entry, "light")) + hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery ) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 42e3516f085..240a5e48287 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -3,7 +3,7 @@ import logging from homeassistant.helpers.entity import ToggleEntity -from .const import VS_FANS, VS_SWITCHES +from .const import VS_FANS, VS_LIGHTS, VS_SWITCHES _LOGGER = logging.getLogger(__name__) @@ -13,6 +13,7 @@ async def async_process_devices(hass, manager): devices = {} devices[VS_SWITCHES] = [] devices[VS_FANS] = [] + devices[VS_LIGHTS] = [] await hass.async_add_executor_job(manager.update) @@ -28,7 +29,9 @@ async def async_process_devices(hass, manager): for switch in manager.switches: if not switch.is_dimmable(): devices[VS_SWITCHES].append(switch) - _LOGGER.info("%d VeSync standard switches found", len(manager.switches)) + else: + devices[VS_LIGHTS].append(switch) + _LOGGER.info("%d VeSync switches found", len(manager.switches)) return devices diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 9923ab94ecf..5d9dfc8aa5d 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -7,4 +7,5 @@ SERVICE_UPDATE_DEVS = "update_devices" VS_SWITCHES = "switches" VS_FANS = "fans" +VS_LIGHTS = "lights" VS_MANAGER = "manager" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 7cc3f00e1a0..1d1320d8d78 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,16 +1,15 @@ """Support for VeSync fans.""" import logging +import math -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .common import VeSyncDevice from .const import DOMAIN, VS_DISCOVERY, VS_DISPATCHERS, VS_FANS @@ -21,8 +20,11 @@ DEV_TYPE_TO_HA = { "LV-PUR131S": "fan", } -FAN_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] FAN_MODE_AUTO = "auto" +FAN_MODE_SLEEP = "sleep" + +PRESET_MODES = [FAN_MODE_AUTO, FAN_MODE_SLEEP] +SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -68,20 +70,30 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return SUPPORT_SET_SPEED @property - def speed(self): + def percentage(self): """Return the current speed.""" - if self.smartfan.mode == FAN_MODE_AUTO: - return None if self.smartfan.mode == "manual": current_level = self.smartfan.fan_level if current_level is not None: - return FAN_SPEEDS[current_level] + return ranged_value_to_percentage(SPEED_RANGE, current_level) return None @property - def speed_list(self): - """Get the list of available speeds.""" - return FAN_SPEEDS + 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): + """Get the list of available preset modes.""" + return PRESET_MODES + + @property + def preset_mode(self): + """Get the current preset mode.""" + if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP): + return self.smartfan.mode + return None @property def unique_info(self): @@ -99,15 +111,49 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): "screen_status": self.smartfan.screen_status, } - def set_speed(self, speed): + def set_percentage(self, percentage): """Set the speed of the device.""" + if percentage == 0: + self.smartfan.turn_off() + return + if not self.smartfan.is_on: self.smartfan.turn_on() self.smartfan.manual_mode() - self.smartfan.change_fan_speed(FAN_SPEEDS.index(speed)) + self.smartfan.change_fan_speed( + math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + ) + self.schedule_update_ha_state() - def turn_on(self, speed: str = None, **kwargs) -> None: + def set_preset_mode(self, preset_mode): + """Set the preset mode of device.""" + if preset_mode not in self.preset_modes: + raise ValueError( + "{preset_mode} is not one of the valid preset modes: {self.preset_modes}" + ) + + if not self.smartfan.is_on: + self.smartfan.turn_on() + + if preset_mode == FAN_MODE_AUTO: + self.smartfan.auto_mode() + elif preset_mode == FAN_MODE_SLEEP: + self.smartfan.sleep_mode() + + self.schedule_update_ha_state() + + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" - self.smartfan.turn_on() - self.set_speed(speed) + if preset_mode: + self.set_preset_mode(preset_mode) + return + if percentage is None: + percentage = 50 + self.set_percentage(percentage) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py new file mode 100644 index 00000000000..53dfdc5f0a9 --- /dev/null +++ b/homeassistant/components/vesync/light.py @@ -0,0 +1,85 @@ +"""Support for VeSync dimmers.""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .common import VeSyncDevice +from .const import DOMAIN, VS_DISCOVERY, VS_DISPATCHERS, VS_LIGHTS + +_LOGGER = logging.getLogger(__name__) + +DEV_TYPE_TO_HA = { + "ESD16": "light", + "ESWD16": "light", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up lights.""" + + async def async_discover(devices): + """Add new devices to platform.""" + _async_setup_entities(devices, async_add_entities) + + disp = async_dispatcher_connect( + hass, VS_DISCOVERY.format(VS_LIGHTS), async_discover + ) + hass.data[DOMAIN][VS_DISPATCHERS].append(disp) + + _async_setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities) + return True + + +@callback +def _async_setup_entities(devices, async_add_entities): + """Check if device is online and add entity.""" + dev_list = [] + for dev in devices: + if DEV_TYPE_TO_HA.get(dev.device_type) == "light": + dev_list.append(VeSyncDimmerHA(dev)) + else: + _LOGGER.debug( + "%s - Unknown device type - %s", dev.device_name, dev.device_type + ) + continue + + async_add_entities(dev_list, update_before_add=True) + + +class VeSyncDimmerHA(VeSyncDevice, LightEntity): + """Representation of a VeSync dimmer.""" + + def __init__(self, dimmer): + """Initialize the VeSync dimmer device.""" + super().__init__(dimmer) + self.dimmer = dimmer + + def turn_on(self, **kwargs): + """Turn the device on.""" + if ATTR_BRIGHTNESS in kwargs: + # get brightness from HA data + brightness = int(kwargs[ATTR_BRIGHTNESS]) + # convert to percent that vesync api expects + brightness = round((brightness / 255) * 100) + # clamp to 1-100 + brightness = max(1, min(brightness, 100)) + self.dimmer.set_brightness(brightness) + # Avoid turning device back on if this is just a brightness adjustment + if not self.is_on: + self.device.turn_on() + + @property + def supported_features(self): + """Get supported features for this entity.""" + return SUPPORT_BRIGHTNESS + + @property + def brightness(self): + """Get dimmer brightness.""" + return round((int(self.dimmer.brightness) / 100) * 255) diff --git a/homeassistant/components/vesync/translations/ko.json b/homeassistant/components/vesync/translations/ko.json index 888bcd66231..d11e9f9459d 100644 --- a/homeassistant/components/vesync/translations/ko.json +++ b/homeassistant/components/vesync/translations/ko.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/vesync/translations/nl.json b/homeassistant/components/vesync/translations/nl.json index 0dc21373c14..36c7f315bcc 100644 --- a/homeassistant/components/vesync/translations/nl.json +++ b/homeassistant/components/vesync/translations/nl.json @@ -3,6 +3,9 @@ "abort": { "single_instance_allowed": "Al geconfigureerd. Slecht \u00e9\u00e9n configuratie mogelijk." }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/vesync/translations/ru.json b/homeassistant/components/vesync/translations/ru.json index fd6132565f6..b3ac09685be 100644 --- a/homeassistant/components/vesync/translations/ru.json +++ b/homeassistant/components/vesync/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." }, "error": { - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 9ae615a6367..b7e926b2379 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -23,8 +23,16 @@ _LOGGER = logging.getLogger(__name__) CONF_GETTER = "getter" SENSOR_CIRCULATION_PUMP_ACTIVE = "circulationpump_active" + +# gas sensors SENSOR_BURNER_ACTIVE = "burner_active" + +# heatpump sensors SENSOR_COMPRESSOR_ACTIVE = "compressor_active" +SENSOR_HEATINGROD_OVERALL = "heatingrod_overall" +SENSOR_HEATINGROD_LEVEL1 = "heatingrod_level1" +SENSOR_HEATINGROD_LEVEL2 = "heatingrod_level2" +SENSOR_HEATINGROD_LEVEL3 = "heatingrod_level3" SENSOR_TYPES = { SENSOR_CIRCULATION_PUMP_ACTIVE: { @@ -44,13 +52,39 @@ SENSOR_TYPES = { CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, CONF_GETTER: lambda api: api.getCompressorActive(), }, + SENSOR_HEATINGROD_OVERALL: { + CONF_NAME: "Heating rod overall", + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_GETTER: lambda api: api.getHeatingRodStatusOverall(), + }, + SENSOR_HEATINGROD_LEVEL1: { + CONF_NAME: "Heating rod level 1", + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_GETTER: lambda api: api.getHeatingRodStatusLevel1(), + }, + SENSOR_HEATINGROD_LEVEL2: { + CONF_NAME: "Heating rod level 2", + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_GETTER: lambda api: api.getHeatingRodStatusLevel2(), + }, + SENSOR_HEATINGROD_LEVEL3: { + CONF_NAME: "Heating rod level 3", + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_GETTER: lambda api: api.getHeatingRodStatusLevel3(), + }, } SENSORS_GENERIC = [SENSOR_CIRCULATION_PUMP_ACTIVE] SENSORS_BY_HEATINGTYPE = { HeatingType.gas: [SENSOR_BURNER_ACTIVE], - HeatingType.heatpump: [SENSOR_COMPRESSOR_ACTIVE], + HeatingType.heatpump: [ + SENSOR_COMPRESSOR_ACTIVE, + SENSOR_HEATINGROD_OVERALL, + SENSOR_HEATINGROD_LEVEL1, + SENSOR_HEATINGROD_LEVEL2, + SENSOR_HEATINGROD_LEVEL3, + ], } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 5e14795d540..a14e00923c2 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, PERCENTAGE, TEMP_CELSIUS, + TIME_HOURS, ) from homeassistant.helpers.entity import Entity @@ -52,6 +53,11 @@ SENSOR_GAS_CONSUMPTION_THIS_YEAR = "gas_consumption_heating_this_year" # heatpump sensors SENSOR_COMPRESSOR_STARTS = "compressor_starts" SENSOR_COMPRESSOR_HOURS = "compressor_hours" +SENSOR_COMPRESSOR_HOURS_LOADCLASS1 = "compressor_hours_loadclass1" +SENSOR_COMPRESSOR_HOURS_LOADCLASS2 = "compressor_hours_loadclass2" +SENSOR_COMPRESSOR_HOURS_LOADCLASS3 = "compressor_hours_loadclass3" +SENSOR_COMPRESSOR_HOURS_LOADCLASS4 = "compressor_hours_loadclass4" +SENSOR_COMPRESSOR_HOURS_LOADCLASS5 = "compressor_hours_loadclass5" SENSOR_TYPES = { SENSOR_OUTSIDE_TEMPERATURE: { @@ -149,7 +155,7 @@ SENSOR_TYPES = { SENSOR_BURNER_HOURS: { CONF_NAME: "Burner Hours", CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, CONF_GETTER: lambda api: api.getBurnerHours(), CONF_DEVICE_CLASS: None, }, @@ -164,10 +170,45 @@ SENSOR_TYPES = { SENSOR_COMPRESSOR_HOURS: { CONF_NAME: "Compressor Hours", CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, CONF_GETTER: lambda api: api.getCompressorHours(), CONF_DEVICE_CLASS: None, }, + SENSOR_COMPRESSOR_HOURS_LOADCLASS1: { + CONF_NAME: "Compressor Hours Load Class 1", + CONF_ICON: "mdi:counter", + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, + CONF_GETTER: lambda api: api.getCompressorHoursLoadClass1(), + CONF_DEVICE_CLASS: None, + }, + SENSOR_COMPRESSOR_HOURS_LOADCLASS2: { + CONF_NAME: "Compressor Hours Load Class 2", + CONF_ICON: "mdi:counter", + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, + CONF_GETTER: lambda api: api.getCompressorHoursLoadClass2(), + CONF_DEVICE_CLASS: None, + }, + SENSOR_COMPRESSOR_HOURS_LOADCLASS3: { + CONF_NAME: "Compressor Hours Load Class 3", + CONF_ICON: "mdi:counter", + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, + CONF_GETTER: lambda api: api.getCompressorHoursLoadClass3(), + CONF_DEVICE_CLASS: None, + }, + SENSOR_COMPRESSOR_HOURS_LOADCLASS4: { + CONF_NAME: "Compressor Hours Load Class 4", + CONF_ICON: "mdi:counter", + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, + CONF_GETTER: lambda api: api.getCompressorHoursLoadClass4(), + CONF_DEVICE_CLASS: None, + }, + SENSOR_COMPRESSOR_HOURS_LOADCLASS5: { + CONF_NAME: "Compressor Hours Load Class 5", + CONF_ICON: "mdi:counter", + CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, + CONF_GETTER: lambda api: api.getCompressorHoursLoadClass5(), + CONF_DEVICE_CLASS: None, + }, SENSOR_RETURN_TEMPERATURE: { CONF_NAME: "Return Temperature", CONF_ICON: None, @@ -195,8 +236,13 @@ SENSORS_BY_HEATINGTYPE = { SENSOR_GAS_CONSUMPTION_THIS_YEAR, ], HeatingType.heatpump: [ - SENSOR_COMPRESSOR_HOURS, SENSOR_COMPRESSOR_STARTS, + SENSOR_COMPRESSOR_HOURS, + SENSOR_COMPRESSOR_HOURS_LOADCLASS1, + SENSOR_COMPRESSOR_HOURS_LOADCLASS2, + SENSOR_COMPRESSOR_HOURS_LOADCLASS3, + SENSOR_COMPRESSOR_HOURS_LOADCLASS4, + SENSOR_COMPRESSOR_HOURS_LOADCLASS5, SENSOR_RETURN_TEMPERATURE, ], } diff --git a/homeassistant/components/vilfo/translations/ko.json b/homeassistant/components/vilfo/translations/ko.json index 70a315ae703..4b79130c750 100644 --- a/homeassistant/components/vilfo/translations/ko.json +++ b/homeassistant/components/vilfo/translations/ko.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\uc774 Vilfo \ub77c\uc6b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc785\ub825\ud558\uc2e0 \ub0b4\uc6a9\uc744 \ud655\uc778\ud558\uc2e0 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc2a4 \ud1a0\ud070\uc744 \ud655\uc778\ud558\uc2e0 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", - "unknown": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud558\ub294 \uc911 \uc608\uae30\uce58 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/vilfo/translations/ru.json b/homeassistant/components/vilfo/translations/ru.json index 8e61be90400..62ec2fe5dae 100644 --- a/homeassistant/components/vilfo/translations/ru.json +++ b/homeassistant/components/vilfo/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/vilfo/translations/zh-Hant.json b/homeassistant/components/vilfo/translations/zh-Hant.json index b266e25b39c..88180f9bacf 100644 --- a/homeassistant/components/vilfo/translations/zh-Hant.json +++ b/homeassistant/components/vilfo/translations/zh-Hant.json @@ -11,10 +11,10 @@ "step": { "user": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470", + "access_token": "\u5b58\u53d6\u6b0a\u6756", "host": "\u4e3b\u6a5f\u7aef" }, - "description": "\u8a2d\u5b9a Vilfo \u8def\u7531\u5668\u6574\u5408\u3002\u9700\u8981\u8f38\u5165 Vilfo \u8def\u7531\u5668\u4e3b\u6a5f\u540d\u7a31/IP \u4f4d\u5740\u3001API \u5b58\u53d6\u5bc6\u9470\u3002\u5176\u4ed6\u6574\u5408\u76f8\u95dc\u8cc7\u8a0a\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/vilfo", + "description": "\u8a2d\u5b9a Vilfo \u8def\u7531\u5668\u6574\u5408\u3002\u9700\u8981\u8f38\u5165 Vilfo \u8def\u7531\u5668\u4e3b\u6a5f\u540d\u7a31/IP \u4f4d\u5740\u3001API \u5b58\u53d6\u6b0a\u6756\u3002\u5176\u4ed6\u6574\u5408\u76f8\u95dc\u8cc7\u8a0a\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/vilfo", "title": "\u9023\u7dda\u81f3 Vilfo \u8def\u7531\u5668" } } diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 40f71adda12..3f57cdb81fa 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -206,7 +206,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Dict[str, Any] = None ) -> Dict[str, Any]: """Handle a flow initialized by the user.""" - assert self.hass errors = {} if user_input is not None: @@ -232,7 +231,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "existing_config_entry_found" if not errors: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if self._must_show_form and self.context["source"] == SOURCE_ZEROCONF: # Discovery should always display the config form before trying to # create entry so that user can update default config options @@ -251,7 +249,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not errors: return await self._create_entry(user_input) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: # Import should always display the config form if CONF_ACCESS_TOKEN # wasn't included but is needed so that the user can choose to update @@ -271,7 +268,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): schema = self._user_schema or _get_config_schema() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if errors and self.context["source"] == SOURCE_IMPORT: # Log an error message if import config flow fails since otherwise failure is silent _LOGGER.error( @@ -346,8 +342,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, discovery_info: Optional[DiscoveryInfoType] = None ) -> Dict[str, Any]: """Handle zeroconf discovery.""" - assert self.hass - # If host already has port, no need to add it again if ":" not in discovery_info[CONF_HOST]: discovery_info[ @@ -432,7 +426,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token self._must_show_form = True - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if self.context["source"] == SOURCE_IMPORT: # If user is pairing via config import, show different message return await self.async_step_pairing_complete_import() diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 4c06c89692a..53c8a2bba88 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -25,7 +25,6 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -115,10 +114,6 @@ async def async_setup_entry( timeout=DEFAULT_TIMEOUT, ) - if not await device.can_connect_with_auth_check(): - _LOGGER.warning("Failed to connect to %s", host) - raise PlatformNotReady - apps_coordinator = hass.data[DOMAIN].get(CONF_APPS) entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator) @@ -183,12 +178,6 @@ class VizioDevice(MediaPlayerEntity): async def async_update(self) -> None: """Retrieve latest state of the device.""" - if not self._model: - self._model = await self._device.get_model_name(log_api_exception=False) - - if not self._sw_version: - self._sw_version = await self._device.get_version(log_api_exception=False) - is_on = await self._device.get_power_state(log_api_exception=False) if is_on is None: @@ -205,6 +194,12 @@ class VizioDevice(MediaPlayerEntity): ) self._available = True + if not self._model: + self._model = await self._device.get_model_name(log_api_exception=False) + + if not self._sw_version: + self._sw_version = await self._device.get_version(log_api_exception=False) + if not is_on: self._state = STATE_OFF self._volume_level = None diff --git a/homeassistant/components/vizio/services.yaml b/homeassistant/components/vizio/services.yaml index 50bde6cab78..7a2ea859b7d 100644 --- a/homeassistant/components/vizio/services.yaml +++ b/homeassistant/components/vizio/services.yaml @@ -1,15 +1,33 @@ update_setting: - description: Update the value of a setting on a particular Vizio media player device. + name: Update setting + description: Update the value of a setting on a Vizio media player device. + target: + entity: + integration: vizio + domain: media_player fields: - entity_id: - description: Name of an entity to send command to. - example: "media_player.vizio_smartcast" setting_type: - description: The type of setting to be changed. Available types are listed in the `setting_types` property. + name: Setting type + description: + The type of setting to be changed. Available types are listed in the + 'setting_types' property. + required: true example: "audio" + selector: + text: setting_name: - description: The name of the setting to be changed. Available settings for a given setting_type are listed in the `_settings` property. + name: Setting name + description: + The name of the setting to be changed. Available settings for a given + setting_type are listed in the '_settings' property. + required: true example: "eq" + selector: + text: new_value: - description: The new value for the setting + name: New value + description: The new value for the setting. + required: true example: "Music" + selector: + text: diff --git a/homeassistant/components/vizio/translations/fr.json b/homeassistant/components/vizio/translations/fr.json index 89c46fd5959..5fc9158c803 100644 --- a/homeassistant/components/vizio/translations/fr.json +++ b/homeassistant/components/vizio/translations/fr.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de la connexion ", "updated_entry": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais le nom et/ou les options d\u00e9finis dans la configuration ne correspondent pas \u00e0 la configuration pr\u00e9c\u00e9demment import\u00e9e, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence." }, "error": { diff --git a/homeassistant/components/vizio/translations/ko.json b/homeassistant/components/vizio/translations/ko.json index ef10cb1f4fc..037c85d7c4e 100644 --- a/homeassistant/components/vizio/translations/ko.json +++ b/homeassistant/components/vizio/translations/ko.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured_device": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "updated_entry": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc9c0\ub9cc \uad6c\uc131\uc5d0 \uc815\uc758\ub41c \uc774\ub984, \uc571 \ud639\uc740 \uc635\uc158\uc774 \uc774\uc804\uc5d0 \uac00\uc838\uc628 \uad6c\uc131 \ub0b4\uc6a9\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uad6c\uc131 \ud56d\ubaa9\uc774 \uadf8\uc5d0 \ub530\ub77c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { @@ -12,7 +13,7 @@ "step": { "pair_tv": { "data": { - "pin": "PIN" + "pin": "PIN \ucf54\ub4dc" }, "description": "TV \uc5d0 \ucf54\ub4dc\uac00 \ud45c\uc2dc\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ucf54\ub4dc\ub97c \uc785\ub825\ub780\uc5d0 \uc785\ub825\ud55c \ud6c4 \ub2e4\uc74c \ub2e8\uacc4\ub97c \uacc4\uc18d\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \uc644\ub8cc\ud574\uc8fc\uc138\uc694.", "title": "\ud398\uc5b4\ub9c1 \uacfc\uc815 \ub05d\ub0b4\uae30" diff --git a/homeassistant/components/vizio/translations/nl.json b/homeassistant/components/vizio/translations/nl.json index 9841eaa7f50..48fd831d61c 100644 --- a/homeassistant/components/vizio/translations/nl.json +++ b/homeassistant/components/vizio/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured_device": "Dit apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", "updated_entry": "Dit item is al ingesteld, maar de naam en/of opties die zijn gedefinieerd in de configuratie komen niet overeen met de eerder ge\u00efmporteerde configuratie, dus het configuratie-item is dienovereenkomstig bijgewerkt." }, "error": { diff --git a/homeassistant/components/vizio/translations/sv.json b/homeassistant/components/vizio/translations/sv.json index 8e5ebe47c43..82483d80fe8 100644 --- a/homeassistant/components/vizio/translations/sv.json +++ b/homeassistant/components/vizio/translations/sv.json @@ -4,6 +4,19 @@ "updated_entry": "Den h\u00e4r posten har redan konfigurerats, men namnet och/eller alternativen som definierats i konfigurationen matchar inte den tidigare importerade konfigurationen och d\u00e4rf\u00f6r har konfigureringsposten uppdaterats i enlighet med detta." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN-kod" + }, + "description": "Din TV borde visa en kod. Skriv koden i formul\u00e4ret och forts\u00e4tt sedan till n\u00e4sta steg f\u00f6r att slutf\u00f6ra parningen.", + "title": "Slutf\u00f6r parningsprocessen" + }, + "pairing_complete": { + "title": "Parkopplingen slutf\u00f6rd" + }, + "pairing_complete_import": { + "title": "Parkopplingen slutf\u00f6rd" + }, "user": { "data": { "access_token": "\u00c5tkomstnyckel", diff --git a/homeassistant/components/vizio/translations/zh-Hant.json b/homeassistant/components/vizio/translations/zh-Hant.json index 257ed829b6a..5f21dd0c2b6 100644 --- a/homeassistant/components/vizio/translations/zh-Hant.json +++ b/homeassistant/components/vizio/translations/zh-Hant.json @@ -23,17 +23,17 @@ "title": "\u914d\u5c0d\u5b8c\u6210" }, "pairing_complete_import": { - "description": "VIZIO SmartCast \u88dd\u7f6e \u5df2\u9023\u7dda\u81f3 Home Assistant\u3002\n\n\u5b58\u53d6\u5bc6\u9470\u70ba '**{access_token}**'\u3002", + "description": "VIZIO SmartCast \u88dd\u7f6e \u5df2\u9023\u7dda\u81f3 Home Assistant\u3002\n\n\u5b58\u53d6\u6b0a\u6756\u70ba '**{access_token}**'\u3002", "title": "\u914d\u5c0d\u5b8c\u6210" }, "user": { "data": { - "access_token": "\u5b58\u53d6\u5bc6\u9470", + "access_token": "\u5b58\u53d6\u6b0a\u6756", "device_class": "\u88dd\u7f6e\u985e\u5225", "host": "\u4e3b\u6a5f\u7aef", "name": "\u540d\u7a31" }, - "description": "\u6b64\u96fb\u8996\u50c5\u9700\u5b58\u53d6\u5bc6\u9470\u5047\u5982\u60a8\u6b63\u5728\u8a2d\u5b9a\u96fb\u8996\u3001\u5c1a\u672a\u53d6\u5f97\u5b58\u53d6\u5bc6\u9470 \uff0c\u4fdd\u6301\u7a7a\u767d\u4ee5\u9032\u884c\u914d\u5c0d\u904e\u7a0b\u3002", + "description": "\u6b64\u96fb\u8996\u50c5\u9700\u5b58\u53d6\u6b0a\u6756\u5047\u5982\u60a8\u6b63\u5728\u8a2d\u5b9a\u96fb\u8996\u3001\u5c1a\u672a\u53d6\u5f97\u5b58\u53d6\u6b0a\u6756 \uff0c\u4fdd\u6301\u7a7a\u767d\u4ee5\u9032\u884c\u914d\u5c0d\u904e\u7a0b\u3002", "title": "VIZIO SmartCast \u88dd\u7f6e" } } diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json index f6e4aa04521..37941e15458 100644 --- a/homeassistant/components/vlc_telnet/manifest.json +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -2,6 +2,6 @@ "domain": "vlc_telnet", "name": "VLC media player Telnet", "documentation": "https://www.home-assistant.io/integrations/vlc-telnet", - "requirements": ["python-telnet-vlc==1.0.4"], - "codeowners": ["@rodripf"] + "requirements": ["python-telnet-vlc==2.0.1"], + "codeowners": ["@rodripf", "@dmcc"] } diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 1f0d62b6ee8..68b3c373c7a 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -1,7 +1,13 @@ """Provide functionality to interact with the vlc telnet interface.""" import logging -from python_telnet_vlc import ConnectionError as ConnErr, VLCTelnet +from python_telnet_vlc import ( + CommandError, + ConnectionError as ConnErr, + LuaError, + ParseError, + VLCTelnet, +) import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity @@ -37,6 +43,7 @@ DOMAIN = "vlc_telnet" DEFAULT_NAME = "VLC-TELNET" DEFAULT_PORT = 4212 +MAX_VOLUME = 500 SUPPORT_VLC = ( SUPPORT_PAUSE @@ -81,7 +88,6 @@ class VlcDevice(MediaPlayerEntity): def __init__(self, name, host, port, passwd): """Initialize the vlc device.""" - self._instance = None self._name = name self._volume = None self._muted = None @@ -93,7 +99,7 @@ class VlcDevice(MediaPlayerEntity): self._port = port self._password = passwd self._vlc = None - self._available = False + self._available = True self._volume_bkp = 0 self._media_artist = "" self._media_title = "" @@ -103,43 +109,54 @@ class VlcDevice(MediaPlayerEntity): if self._vlc is None: try: self._vlc = VLCTelnet(self._host, self._password, self._port) - self._state = STATE_IDLE - self._available = True - except (ConnErr, EOFError): - self._available = False + except (ConnErr, EOFError) as err: + if self._available: + _LOGGER.error("Connection error: %s", err) + self._available = False self._vlc = None - else: - try: - status = self._vlc.status() - if status: - if "volume" in status: - self._volume = int(status["volume"]) / 500.0 - else: - self._volume = None - if "state" in status: - state = status["state"] - if state == "playing": - self._state = STATE_PLAYING - elif state == "paused": - self._state = STATE_PAUSED - else: - self._state = STATE_IDLE + return + + self._state = STATE_IDLE + self._available = True + + try: + status = self._vlc.status() + _LOGGER.debug("Status: %s", status) + + if status: + if "volume" in status: + self._volume = int(status["volume"]) / 500.0 + else: + self._volume = None + if "state" in status: + state = status["state"] + if state == "playing": + self._state = STATE_PLAYING + elif state == "paused": + self._state = STATE_PAUSED else: self._state = STATE_IDLE + else: + self._state = STATE_IDLE + if self._state != STATE_IDLE: self._media_duration = self._vlc.get_length() self._media_position = self._vlc.get_time() - info = self._vlc.info() - if info: - self._media_artist = info[0].get("artist") - self._media_title = info[0].get("title") + info = self._vlc.info() + _LOGGER.debug("Info: %s", info) - except (ConnErr, EOFError): + if info: + self._media_artist = info.get(0, {}).get("artist") + self._media_title = info.get(0, {}).get("title") + + except (CommandError, LuaError, ParseError) as err: + _LOGGER.error("Command error: %s", err) + except (ConnErr, EOFError) as err: + if self._available: + _LOGGER.error("Connection error: %s", err) self._available = False - self._vlc = None - - return True + self._vlc = None @property def name(self): @@ -210,17 +227,15 @@ class VlcDevice(MediaPlayerEntity): """Mute the volume.""" if mute: self._volume_bkp = self._volume - self._volume = 0 - self._vlc.set_volume("0") + self.set_volume_level(0) else: - self._vlc.set_volume(str(self._volume_bkp)) - self._volume = self._volume_bkp + self.set_volume_level(self._volume_bkp) self._muted = mute def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._vlc.set_volume(str(volume * 500)) + self._vlc.set_volume(volume * MAX_VOLUME) self._volume = volume def media_play(self): @@ -230,7 +245,11 @@ class VlcDevice(MediaPlayerEntity): def media_pause(self): """Send pause command.""" - self._vlc.pause() + current_state = self._vlc.status().get("state") + if current_state != "paused": + # Make sure we're not already paused since VLCTelnet.pause() toggles + # pause. + self._vlc.pause() self._state = STATE_PAUSED def media_stop(self): diff --git a/homeassistant/components/volumio/translations/ko.json b/homeassistant/components/volumio/translations/ko.json new file mode 100644 index 00000000000..2c630e533ff --- /dev/null +++ b/homeassistant/components/volumio/translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/nl.json b/homeassistant/components/volumio/translations/nl.json index 9179418def9..9e11dbad82b 100644 --- a/homeassistant/components/volumio/translations/nl.json +++ b/homeassistant/components/volumio/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "unknown": "Onverwachte fout" }, "step": { diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 7b7dffbef18..792fcc25eff 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -8,6 +8,7 @@ from volvooncall import Connection from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, + CONF_REGION, CONF_RESOURCES, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -32,7 +33,6 @@ _LOGGER = logging.getLogger(__name__) MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) -CONF_REGION = "region" CONF_SERVICE_URL = "service_url" CONF_SCANDINAVIAN_MILES = "scandinavian_miles" CONF_MUTABLE = "mutable" diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 9b2af2ea7fe..ad989ec39fc 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -8,7 +8,6 @@ import homeassistant.helpers.config_validation as cv CONF_URL = "watson_url" CONF_APIKEY = "watson_apikey" -ATTR_CREDENTIALS = "credentials" DEFAULT_URL = "https://stream.watsonplatform.net/text-to-speech/api" diff --git a/homeassistant/components/weather/translations/et.json b/homeassistant/components/weather/translations/et.json index f035d37d62e..2de9158c085 100644 --- a/homeassistant/components/weather/translations/et.json +++ b/homeassistant/components/weather/translations/et.json @@ -3,7 +3,7 @@ "_": { "clear-night": "Selge \u00f6\u00f6", "cloudy": "Pilves", - "exceptional": "Erakordne", + "exceptional": "Ohtlikud olud", "fog": "Udu", "hail": "Rahe", "lightning": "\u00c4ikeseline", diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 77521c1ed98..ddd7548cd68 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -121,6 +121,7 @@ def handle_unsubscribe_events(hass, connection, msg): vol.Required("type"): "call_service", vol.Required("domain"): str, vol.Required("service"): str, + vol.Optional("target"): cv.ENTITY_SERVICE_FIELDS, vol.Optional("service_data"): dict, } ) @@ -139,6 +140,7 @@ async def handle_call_service(hass, connection, msg): msg.get("service_data"), blocking, context, + target=msg.get("target"), ) connection.send_message( messages.result_message(msg["id"], {"context": context}) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 75ca322b9a3..df737f101ba 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,9 +1,7 @@ """Support for WeMo device discovery.""" -import asyncio import logging import pywemo -import requests import voluptuous as vol from homeassistant import config_entries @@ -12,14 +10,19 @@ from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 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.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.util.async_ import gather_with_concurrency from .const import DOMAIN +# Max number of devices to initialize at once. This limit is in place to +# avoid tying up too many executor threads with WeMo device setup. +MAX_CONCURRENCY = 3 + # Mapping from Wemo model_name to domain. WEMO_MODEL_DISPATCH = { "Bridge": LIGHT_DOMAIN, @@ -57,7 +60,6 @@ def coerce_host_port(value): CONF_STATIC = "static" -CONF_DISCOVERY = "discovery" DEFAULT_DISCOVERY = True @@ -116,11 +118,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): static_conf = config.get(CONF_STATIC, []) if static_conf: _LOGGER.debug("Adding statically configured WeMo devices...") - for device in await asyncio.gather( + for device in await gather_with_concurrency( + MAX_CONCURRENCY, *[ hass.async_add_executor_job(validate_static_config, host, port) for host, port in static_conf - ] + ], ): if device: wemo_dispatcher.async_add_unique_device(hass, device) @@ -189,15 +192,44 @@ class WemoDiscovery: self._wemo_dispatcher = wemo_dispatcher self._stop = None self._scan_delay = 0 + self._upnp_entries = set() + + async def async_add_from_upnp_entry(self, entry: pywemo.ssdp.UPNPEntry) -> None: + """Create a WeMoDevice from an UPNPEntry and add it to the dispatcher. + + Uses the self._upnp_entries set to avoid interrogating the same device + multiple times. + """ + if entry in self._upnp_entries: + return + try: + device = await self._hass.async_add_executor_job( + pywemo.discovery.device_from_uuid_and_location, + entry.udn, + entry.location, + ) + except pywemo.PyWeMoException as err: + _LOGGER.error("Unable to setup WeMo %r (%s)", entry, err) + else: + self._wemo_dispatcher.async_add_unique_device(self._hass, device) + self._upnp_entries.add(entry) async def async_discover_and_schedule(self, *_) -> None: """Periodically scan the network looking for WeMo devices.""" _LOGGER.debug("Scanning network for WeMo devices...") try: - for device in await self._hass.async_add_executor_job( - pywemo.discover_devices - ): - self._wemo_dispatcher.async_add_unique_device(self._hass, device) + # pywemo.ssdp.scan is a light-weight UDP UPnP scan for WeMo devices. + entries = await self._hass.async_add_executor_job(pywemo.ssdp.scan) + + # async_add_from_upnp_entry causes multiple HTTP requests to be sent + # to the WeMo device for the initial setup of the WeMoDevice + # instance. This may take some time to complete. The per-device + # setup work is done in parallel to speed up initial setup for the + # component. + await gather_with_concurrency( + MAX_CONCURRENCY, + *[self.async_add_from_upnp_entry(entry) for entry in entries], + ) finally: # Run discovery more frequently after hass has just started. self._scan_delay = min( @@ -230,10 +262,10 @@ def validate_static_config(host, port): return None try: - device = pywemo.discovery.device_from_description(url, None) + device = pywemo.discovery.device_from_description(url) except ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, + pywemo.exceptions.ActionException, + pywemo.exceptions.HTTPException, ) as err: _LOGGER.error("Unable to access WeMo at %s (%s)", url, err) return None diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index b6690ed6d28..94d5a587c17 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -2,8 +2,6 @@ import asyncio import logging -from pywemo.ouimeaux_device.api.service import ActionException - from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -35,13 +33,5 @@ class WemoBinarySensor(WemoSubscriptionEntity, BinarySensorEntity): def _update(self, force_update=True): """Update the sensor state.""" - try: + with self._wemo_exception_handler("update status"): self._state = self.wemo.get_state(force_update) - - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - except (AttributeError, ActionException) as err: - _LOGGER.warning("Could not update status for %s (%s)", self.name, err) - self._available = False - self.wemo.reconnect_with_device() diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index e7c0712272c..4fac786af9a 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -1,10 +1,12 @@ """Classes shared among Wemo entities.""" import asyncio +import contextlib import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Generator, Optional import async_timeout from pywemo import WeMoDevice +from pywemo.exceptions import ActionException from homeassistant.helpers.entity import Entity @@ -13,6 +15,18 @@ from .const import DOMAIN as WEMO_DOMAIN _LOGGER = logging.getLogger(__name__) +class ExceptionHandlerStatus: + """Exit status from the _wemo_exception_handler context manager.""" + + # An exception if one was raised in the _wemo_exception_handler. + exception: Optional[Exception] = None + + @property + def success(self) -> bool: + """Return True if the handler completed with no exception.""" + return self.exception is None + + class WemoEntity(Entity): """Common methods for Wemo entities. @@ -25,6 +39,7 @@ class WemoEntity(Entity): self._state = None self._available = True self._update_lock = None + self._has_polled = False @property def name(self) -> str: @@ -36,6 +51,23 @@ class WemoEntity(Entity): """Return true if switch is available.""" return self._available + @contextlib.contextmanager + def _wemo_exception_handler( + self, message: str + ) -> Generator[ExceptionHandlerStatus, None, None]: + """Wrap device calls to set `_available` when wemo exceptions happen.""" + status = ExceptionHandlerStatus() + try: + yield status + except ActionException as err: + status.exception = err + _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) + self._available = False + else: + if not self._available: + _LOGGER.info("Reconnected to %s", self.name) + self._available = True + def _update(self, force_update: Optional[bool] = True): """Update the device state.""" raise NotImplementedError() @@ -49,25 +81,38 @@ class WemoEntity(Entity): """Update WeMo state. Wemo has an aggressive retry logic that sometimes can take over a - minute to return. If we don't get a state after 5 seconds, assume the - Wemo switch is unreachable. If update goes through, it will be made - available again. + minute to return. If we don't get a state within the scan interval, + assume the Wemo switch is unreachable. If update goes through, it will + be made available again. """ # If an update is in progress, we don't do anything if self._update_lock.locked(): return try: - with async_timeout.timeout(5): - await asyncio.shield(self._async_locked_update(True)) + async with async_timeout.timeout( + self.platform.scan_interval.seconds - 0.1 + ) as timeout: + await asyncio.shield(self._async_locked_update(True, timeout)) except asyncio.TimeoutError: _LOGGER.warning("Lost connection to %s", self.name) self._available = False - async def _async_locked_update(self, force_update: bool) -> None: + async def _async_locked_update( + self, force_update: bool, timeout: Optional[async_timeout.timeout] = None + ) -> None: """Try updating within an async lock.""" async with self._update_lock: await self.hass.async_add_executor_job(self._update, force_update) + self._has_polled = True + # When the timeout expires HomeAssistant is no longer waiting for an + # update from the device. Instead, the state needs to be updated + # asynchronously. This also handles the case where an update came + # directly from the device (device push). In that case no polling + # update was involved and the state also needs to be updated + # asynchronously. + if not timeout or timeout.expired: + self.async_write_ha_state() class WemoSubscriptionEntity(WemoEntity): @@ -93,6 +138,24 @@ class WemoSubscriptionEntity(WemoEntity): """Return true if the state is on. Standby is on.""" return self._state + @property + def should_poll(self) -> bool: + """Return True if the the device requires local polling, False otherwise. + + Polling can be disabled if three conditions are met: + 1. The device has polled to get the initial state (self._has_polled). + 2. The polling was successful and the device is in a healthy state + (self.available). + 3. The pywemo subscription registry reports that there is an active + subscription and the subscription has been confirmed by receiving an + initial event. This confirms that device push notifications are + working correctly (registry.is_subscribed - this method is async safe). + """ + registry = self.hass.data[WEMO_DOMAIN]["registry"] + return not ( + self.available and self._has_polled and registry.is_subscribed(self.wemo) + ) + async def async_added_to_hass(self) -> None: """Wemo device added to Home Assistant.""" await super().async_added_to_hass() @@ -121,4 +184,3 @@ class WemoSubscriptionEntity(WemoEntity): return await self._async_locked_update(force_update) - self.async_write_ha_state() diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 0dca71a0d8d..1f45194659d 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -2,20 +2,18 @@ import asyncio from datetime import timedelta import logging +import math -from pywemo.ouimeaux_device.api.service import ActionException import voluptuous as vol -from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .const import ( DOMAIN as WEMO_DOMAIN, @@ -48,37 +46,17 @@ WEMO_HUMIDITY_100 = 4 WEMO_FAN_OFF = 0 WEMO_FAN_MINIMUM = 1 -WEMO_FAN_LOW = 2 # Not used due to limitations of the base fan implementation -WEMO_FAN_MEDIUM = 3 -WEMO_FAN_HIGH = 4 # Not used due to limitations of the base fan implementation +WEMO_FAN_MEDIUM = 4 WEMO_FAN_MAXIMUM = 5 +SPEED_RANGE = (WEMO_FAN_MINIMUM, WEMO_FAN_MAXIMUM) # off is not included + WEMO_WATER_EMPTY = 0 WEMO_WATER_LOW = 1 WEMO_WATER_GOOD = 2 -SUPPORTED_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - SUPPORTED_FEATURES = SUPPORT_SET_SPEED -# Since the base fan object supports a set list of fan speeds, -# we have to reuse some of them when mapping to the 5 WeMo speeds -WEMO_FAN_SPEED_TO_HASS = { - WEMO_FAN_OFF: SPEED_OFF, - WEMO_FAN_MINIMUM: SPEED_LOW, - WEMO_FAN_LOW: SPEED_LOW, # Reusing SPEED_LOW - WEMO_FAN_MEDIUM: SPEED_MEDIUM, - WEMO_FAN_HIGH: SPEED_HIGH, # Reusing SPEED_HIGH - WEMO_FAN_MAXIMUM: SPEED_HIGH, -} - -# Because we reused mappings in the previous dict, we have to filter them -# back out in this dict, or else we would have duplicate keys -HASS_FAN_SPEED_TO_WEMO = { - v: k - for (k, v) in WEMO_FAN_SPEED_TO_HASS.items() - if k not in [WEMO_FAN_LOW, WEMO_FAN_HIGH] -} SET_HUMIDITY_SCHEMA = { vol.Required(ATTR_TARGET_HUMIDITY): vol.All( @@ -122,7 +100,8 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): def __init__(self, device): """Initialize the WeMo switch.""" super().__init__(device) - self._fan_mode = None + self._fan_mode = WEMO_FAN_OFF + self._fan_mode_string = None self._target_humidity = None self._current_humidity = None self._water_level = None @@ -141,21 +120,21 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): return { ATTR_CURRENT_HUMIDITY: self._current_humidity, ATTR_TARGET_HUMIDITY: self._target_humidity, - ATTR_FAN_MODE: self._fan_mode, + ATTR_FAN_MODE: self._fan_mode_string, ATTR_WATER_LEVEL: self._water_level, ATTR_FILTER_LIFE: self._filter_life, ATTR_FILTER_EXPIRED: self._filter_expired, } @property - def speed(self) -> str: - """Return the current speed.""" - return WEMO_FAN_SPEED_TO_HASS.get(self._fan_mode) + def percentage(self) -> str: + """Return the current speed percentage.""" + return ranged_value_to_percentage(SPEED_RANGE, self._fan_mode) @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return SUPPORTED_SPEEDS + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) @property def supported_features(self) -> int: @@ -164,10 +143,11 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): def _update(self, force_update=True): """Update the device state.""" - try: + with self._wemo_exception_handler("update status"): self._state = self.wemo.get_state(force_update) - self._fan_mode = self.wemo.fan_mode_string + self._fan_mode = self.wemo.fan_mode + self._fan_mode_string = self.wemo.fan_mode_string self._target_humidity = self.wemo.desired_humidity_percent self._current_humidity = self.wemo.current_humidity_percent self._water_level = self.wemo.water_level_string @@ -177,46 +157,34 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): if self.wemo.fan_mode != WEMO_FAN_OFF: self._last_fan_on_mode = self.wemo.fan_mode - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - except (AttributeError, ActionException) as err: - _LOGGER.warning("Could not update status for %s (%s)", self.name, err) - self._available = False - self.wemo.reconnect_with_device() - - def turn_on(self, speed: str = None, **kwargs) -> None: - """Turn the switch on.""" - if speed is None: - try: - self.wemo.set_state(self._last_fan_on_mode) - except ActionException as err: - _LOGGER.warning("Error while turning on device %s (%s)", self.name, err) - self._available = False - else: - self.set_speed(speed) - - self.schedule_update_ha_state() + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn the fan on.""" + self.set_percentage(percentage) def turn_off(self, **kwargs) -> None: """Turn the switch off.""" - try: + with self._wemo_exception_handler("turn off"): self.wemo.set_state(WEMO_FAN_OFF) - except ActionException as err: - _LOGGER.warning("Error while turning off device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() - def set_speed(self, speed: str) -> None: + def set_percentage(self, percentage: int) -> None: """Set the fan_mode of the Humidifier.""" - try: - self.wemo.set_state(HASS_FAN_SPEED_TO_WEMO.get(speed)) - except ActionException as err: - _LOGGER.warning( - "Error while setting speed of device %s (%s)", self.name, err - ) - self._available = False + if percentage is None: + named_speed = self._last_fan_on_mode + elif percentage == 0: + named_speed = WEMO_FAN_OFF + else: + named_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + + with self._wemo_exception_handler("set speed"): + self.wemo.set_state(named_speed) self.schedule_update_ha_state() @@ -233,24 +201,14 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): elif target_humidity >= 100: pywemo_humidity = WEMO_HUMIDITY_100 - try: + with self._wemo_exception_handler("set humidity"): self.wemo.set_humidity(pywemo_humidity) - except ActionException as err: - _LOGGER.warning( - "Error while setting humidity of device: %s (%s)", self.name, err - ) - self._available = False self.schedule_update_ha_state() def reset_filter_life(self) -> None: """Reset the filter life to 100%.""" - try: + with self._wemo_exception_handler("reset filter life"): self.wemo.reset_filter_life() - except ActionException as err: - _LOGGER.warning( - "Error while resetting filter life on device: %s (%s)", self.name, err - ) - self._available = False self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 1362c7d483c..bbcdafaf351 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -3,8 +3,6 @@ import asyncio from datetime import timedelta import logging -from pywemo.ouimeaux_device.api.service import ActionException - from homeassistant import util from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -158,7 +156,7 @@ class WemoLight(WemoEntity, LightEntity): "force_update": False, } - try: + with self._wemo_exception_handler("turn on"): if xy_color is not None: self.wemo.set_color(xy_color, transition=transition_time) @@ -167,9 +165,6 @@ class WemoLight(WemoEntity, LightEntity): if self.wemo.turn_on(**turn_on_kwargs): self._state["onoff"] = WEMO_ON - except ActionException as err: - _LOGGER.warning("Error while turning on device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() @@ -177,29 +172,21 @@ class WemoLight(WemoEntity, LightEntity): """Turn the light off.""" transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) - try: + with self._wemo_exception_handler("turn off"): if self.wemo.turn_off(transition=transition_time): self._state["onoff"] = WEMO_OFF - except ActionException as err: - _LOGGER.warning("Error while turning off device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() def _update(self, force_update=True): """Synchronize state with bridge.""" - try: + with self._wemo_exception_handler("update status") as handler: self._update_lights(no_throttle=force_update) self._state = self.wemo.state - except (AttributeError, ActionException) as err: - _LOGGER.warning("Could not update status for %s (%s)", self.name, err) - self._available = False - self.wemo.bridge.reconnect_with_device() - else: + if handler.success: self._is_on = self._state.get("onoff") != WEMO_OFF self._brightness = self._state.get("level", 255) self._color_temp = self._state.get("temperature_mireds") - self._available = True xy_color = self._state.get("color_xy") @@ -229,20 +216,12 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): def _update(self, force_update=True): """Update the device state.""" - try: + with self._wemo_exception_handler("update status"): self._state = self.wemo.get_state(force_update) wemobrightness = int(self.wemo.get_brightness(force_update)) self._brightness = int((wemobrightness * 255) / 100) - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - except (AttributeError, ActionException) as err: - _LOGGER.warning("Could not update status for %s (%s)", self.name, err) - self._available = False - self.wemo.reconnect_with_device() - def turn_on(self, **kwargs): """Turn the dimmer on.""" # Wemo dimmer switches use a range of [0, 100] to control @@ -253,24 +232,18 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): else: brightness = 255 - try: + with self._wemo_exception_handler("turn on"): if self.wemo.on(): self._state = WEMO_ON self.wemo.set_brightness(brightness) - except ActionException as err: - _LOGGER.warning("Error while turning on device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the dimmer off.""" - try: + with self._wemo_exception_handler("turn off"): if self.wemo.off(): self._state = WEMO_OFF - except ActionException as err: - _LOGGER.warning("Error while turning on device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index fe5559b58d6..9d91ab7ef96 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.1"], + "requirements": ["pywemo==0.6.3"], "ssdp": [ { "manufacturer": "Belkin International Inc." @@ -12,5 +12,5 @@ "homekit": { "models": ["Socket", "Wemo"] }, - "codeowners": [] + "codeowners": ["@esev"] } diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 50926e07a11..15b38550b93 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -3,8 +3,6 @@ import asyncio from datetime import datetime, timedelta import logging -from pywemo.ouimeaux_device.api.service import ActionException - from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -140,29 +138,23 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): def turn_on(self, **kwargs): """Turn the switch on.""" - try: + with self._wemo_exception_handler("turn on"): if self.wemo.on(): self._state = WEMO_ON - except ActionException as err: - _LOGGER.warning("Error while turning on device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the switch off.""" - try: + with self._wemo_exception_handler("turn off"): if self.wemo.off(): self._state = WEMO_OFF - except ActionException as err: - _LOGGER.warning("Error while turning off device %s (%s)", self.name, err) - self._available = False self.schedule_update_ha_state() def _update(self, force_update=True): """Update the device state.""" - try: + with self._wemo_exception_handler("update status"): self._state = self.wemo.get_state(force_update) if self.wemo.model_name == "Insight": @@ -173,11 +165,3 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): elif self.wemo.model_name == "CoffeeMaker": self.coffeemaker_mode = self.wemo.mode self._mode_string = self.wemo.mode_string - - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - except (AttributeError, ActionException) as err: - _LOGGER.warning("Could not update status for %s (%s)", self.name, err) - self._available = False - self.wemo.reconnect_with_device() diff --git a/homeassistant/components/wemo/translations/ko.json b/homeassistant/components/wemo/translations/ko.json index a262f7ebd3e..5673c049422 100644 --- a/homeassistant/components/wemo/translations/ko.json +++ b/homeassistant/components/wemo/translations/ko.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Wemo \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Wemo \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "step": { "confirm": { diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 7ec5c3dac5e..0e3c0c6e0da 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -6,14 +6,12 @@ import voluptuous as vol import whois from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, TIME_DAYS +from homeassistant.const import CONF_DOMAIN, CONF_NAME, TIME_DAYS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_DOMAIN = "domain" - DEFAULT_NAME = "Whois" ATTR_EXPIRES = "expires" diff --git a/homeassistant/components/wiffi/translations/ko.json b/homeassistant/components/wiffi/translations/ko.json index c332d3e5f26..74c6568feef 100644 --- a/homeassistant/components/wiffi/translations/ko.json +++ b/homeassistant/components/wiffi/translations/ko.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "port": "\uc11c\ubc84 \ud3ec\ud2b8" + "port": "\ud3ec\ud2b8" }, "title": "WIFFI \uae30\uae30\uc6a9 TCP \uc11c\ubc84 \uc124\uc815\ud558\uae30" } diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 97b48257103..67433772551 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -6,11 +6,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import Entity -from .const import DOMAIN from .parent_device import WiLightParent +DOMAIN = "wilight" + # List the platforms that you want to support. -PLATFORMS = ["fan", "light"] +PLATFORMS = ["cover", "fan", "light"] async def async_setup(hass: HomeAssistant, config: dict): diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 3f1b12395ba..32c66df65a7 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -7,7 +7,7 @@ from homeassistant.components import ssdp from homeassistant.config_entries import CONN_CLASS_LOCAL_PUSH, ConfigFlow from homeassistant.const import CONF_HOST -from .const import DOMAIN # pylint: disable=unused-import +from . import DOMAIN # pylint: disable=unused-import CONF_SERIAL_NUMBER = "serial_number" CONF_MODEL_NAME = "model_name" @@ -15,7 +15,7 @@ CONF_MODEL_NAME = "model_name" WILIGHT_MANUFACTURER = "All Automacao Ltda" # List the components supported by this integration. -ALLOWED_WILIGHT_COMPONENTS = ["light", "fan"] +ALLOWED_WILIGHT_COMPONENTS = ["cover", "fan", "light"] class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): @@ -84,7 +84,6 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(self._serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"name": self._title} return await self.async_step_confirm() diff --git a/homeassistant/components/wilight/const.py b/homeassistant/components/wilight/const.py deleted file mode 100644 index a3d77da44ef..00000000000 --- a/homeassistant/components/wilight/const.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Constants for the WiLight integration.""" - -DOMAIN = "wilight" - -# Item types -ITEM_LIGHT = "light" - -# Light types -LIGHT_ON_OFF = "light_on_off" -LIGHT_DIMMER = "light_dimmer" -LIGHT_COLOR = "light_rgb" - -# Light service support -SUPPORT_NONE = 0 diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py new file mode 100644 index 00000000000..93c9a8c4503 --- /dev/null +++ b/homeassistant/components/wilight/cover.py @@ -0,0 +1,104 @@ +"""Support for WiLight Cover.""" + +from pywilight.const import ( + COVER_V1, + ITEM_COVER, + WL_CLOSE, + WL_CLOSING, + WL_OPEN, + WL_OPENING, + WL_STOP, + WL_STOPPED, +) + +from homeassistant.components.cover import ATTR_POSITION, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import DOMAIN, WiLightDevice + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up WiLight covers from a config entry.""" + parent = hass.data[DOMAIN][entry.entry_id] + + # Handle a discovered WiLight device. + entities = [] + for item in parent.api.items: + if item["type"] != ITEM_COVER: + continue + index = item["index"] + item_name = item["name"] + if item["sub_type"] != COVER_V1: + continue + entity = WiLightCover(parent.api, index, item_name) + entities.append(entity) + + async_add_entities(entities) + + +def wilight_to_hass_position(value): + """Convert wilight position 1..255 to hass format 0..100.""" + return min(100, round((value * 100) / 255)) + + +def hass_to_wilight_position(value): + """Convert hass position 0..100 to wilight 1..255 scale.""" + return min(255, round((value * 255) / 100)) + + +class WiLightCover(WiLightDevice, CoverEntity): + """Representation of a WiLights cover.""" + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + if "position_current" in self._status: + return wilight_to_hass_position(self._status["position_current"]) + return None + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + if "motor_state" not in self._status: + return None + return self._status["motor_state"] == WL_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + if "motor_state" not in self._status: + return None + return self._status["motor_state"] == WL_CLOSING + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if "motor_state" not in self._status or "position_current" not in self._status: + return None + return ( + self._status["motor_state"] == WL_STOPPED + and wilight_to_hass_position(self._status["position_current"]) == 0 + ) + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self._client.cover_command(self._index, WL_OPEN) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + await self._client.cover_command(self._index, WL_CLOSE) + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = hass_to_wilight_position(kwargs[ATTR_POSITION]) + await self._client.set_cover_position(self._index, position) + + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + await self._client.cover_command(self._index, WL_STOP) diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 6d8ad88d6c0..d663dc39ded 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -1,7 +1,6 @@ """Support for WiLight Fan.""" from pywilight.const import ( - DOMAIN, FAN_V1, ITEM_FAN, WL_DIRECTION_FORWARD, @@ -14,19 +13,20 @@ from pywilight.const import ( from homeassistant.components.fan import ( DIRECTION_FORWARD, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) -from . import WiLightDevice +from . import DOMAIN, WiLightDevice -SUPPORTED_SPEEDS = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] +ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] SUPPORTED_FEATURES = SUPPORT_SET_SPEED | SUPPORT_DIRECTION @@ -77,14 +77,20 @@ class WiLightFan(WiLightDevice, FanEntity): return self._status.get("direction", WL_DIRECTION_OFF) != WL_DIRECTION_OFF @property - def speed(self) -> str: - """Return the current speed.""" - return self._status.get("speed", SPEED_HIGH) + def percentage(self) -> str: + """Return the current speed percentage.""" + if "direction" in self._status: + if self._status["direction"] == WL_DIRECTION_OFF: + return 0 + wl_speed = self._status.get("speed") + if wl_speed is None: + return None + return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, wl_speed) @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return SUPPORTED_SPEEDS + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) @property def current_direction(self) -> str: @@ -94,20 +100,28 @@ class WiLightFan(WiLightDevice, FanEntity): self._direction = self._status["direction"] return self._direction - async def async_turn_on(self, speed: str = None, **kwargs): + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" - if speed is None: + if percentage is None: await self._client.set_fan_direction(self._index, self._direction) else: - await self.async_set_speed(speed) + await self.async_set_percentage(percentage) - async def async_set_speed(self, speed: str): + async def async_set_percentage(self, percentage: int): """Set the speed of the fan.""" - wl_speed = WL_SPEED_HIGH - if speed == SPEED_LOW: - wl_speed = WL_SPEED_LOW - if speed == SPEED_MEDIUM: - wl_speed = WL_SPEED_MEDIUM + if percentage == 0: + await self._client.set_fan_direction(self._index, WL_DIRECTION_OFF) + return + if "direction" in self._status: + if self._status["direction"] == WL_DIRECTION_OFF: + await self._client.set_fan_direction(self._index, self._direction) + wl_speed = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) await self._client.set_fan_speed(self._index, wl_speed) async def async_set_direction(self, direction: str): diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index e4bf504165d..0c7206be00c 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -1,5 +1,13 @@ """Support for WiLight lights.""" +from pywilight.const import ( + ITEM_LIGHT, + LIGHT_COLOR, + LIGHT_DIMMER, + LIGHT_ON_OFF, + SUPPORT_NONE, +) + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -10,15 +18,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import WiLightDevice -from .const import ( - DOMAIN, - ITEM_LIGHT, - LIGHT_COLOR, - LIGHT_DIMMER, - LIGHT_ON_OFF, - SUPPORT_NONE, -) +from . import DOMAIN, WiLightDevice def entities_from_discovered_wilight(hass, api_device): diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json index c9f4fb049fc..5b8a93c6039 100644 --- a/homeassistant/components/wilight/manifest.json +++ b/homeassistant/components/wilight/manifest.json @@ -3,7 +3,7 @@ "name": "WiLight", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wilight", - "requirements": ["pywilight==0.0.66"], + "requirements": ["pywilight==0.0.68"], "ssdp": [ { "manufacturer": "All Automacao Ltda" diff --git a/homeassistant/components/wilight/translations/ko.json b/homeassistant/components/wilight/translations/ko.json index 677b104c065..e18250811fc 100644 --- a/homeassistant/components/wilight/translations/ko.json +++ b/homeassistant/components/wilight/translations/ko.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "not_wilight_device": "\uc774 \uc7a5\uce58\ub294 WiLight\uac00 \uc544\ub2d9\ub2c8\ub2e4." }, "step": { diff --git a/homeassistant/components/wink/fan.py b/homeassistant/components/wink/fan.py index 535e7094fcd..3aab66e353d 100644 --- a/homeassistant/components/wink/fan.py +++ b/homeassistant/components/wink/fan.py @@ -40,7 +40,20 @@ class WinkFanDevice(WinkDevice, FanEntity): """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 52e22f9501a..c08ddddf4a5 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -402,8 +402,8 @@ WITHINGS_ATTRIBUTES = [ Measurement.SLEEP_SCORE, GetSleepSummaryField.SLEEP_SCORE, "Sleep score", - "", - None, + const.SCORE_POINTS, + "mdi:medal", SENSOR_DOMAIN, False, UpdateType.POLL, diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index d04327808de..ddf51741c62 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -48,7 +48,6 @@ class WithingsFlowHandler( async def async_step_profile(self, data: dict) -> dict: """Prompt the user to select a user profile.""" errors = {} - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 reauth_profile = ( self.context.get(const.PROFILE) if self.context.get("source") == "reauth" @@ -81,14 +80,12 @@ class WithingsFlowHandler( if data is not None: return await self.async_step_user() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 placeholders = {const.PROFILE: self.context["profile"]} self.context.update({"title_placeholders": placeholders}) return self.async_show_form( step_id="reauth", - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 description_placeholders=placeholders, ) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index c6cad929f81..d88f4e38c6a 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -55,6 +55,7 @@ class Measurement(Enum): WEIGHT_KG = "weight_kg" +SCORE_POINTS = "points" UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}" UOM_FREQUENCY = "times" diff --git a/homeassistant/components/withings/translations/it.json b/homeassistant/components/withings/translations/it.json index 85baeb1f0e0..8fb4dee9918 100644 --- a/homeassistant/components/withings/translations/it.json +++ b/homeassistant/components/withings/translations/it.json @@ -26,7 +26,7 @@ }, "reauth": { "description": "Il profilo \"{profile}\" deve essere autenticato nuovamente per continuare a ricevere i dati Withings.", - "title": "Reautenticare l'integrazione" + "title": "Autenticare nuovamente l'integrazione" } } } diff --git a/homeassistant/components/withings/translations/ko.json b/homeassistant/components/withings/translations/ko.json index 902f3c77e68..38ed96dca67 100644 --- a/homeassistant/components/withings/translations/ko.json +++ b/homeassistant/components/withings/translations/ko.json @@ -2,13 +2,16 @@ "config": { "abort": { "already_configured": "\ud504\ub85c\ud544\uc5d0 \ub300\ud55c \uad6c\uc131\uc774 \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "missing_configuration": "Withings \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc5d0\ub7ec\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ub3c4\uc6c0\ub9d0 \uc139\uc158\uc744 \ud655\uc778\ud558\uc138\uc694({docs_url})" + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, "create_entry": { "default": "Withings \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, + "error": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "flow_title": "Withings: {profile}", "step": { "pick_implementation": { @@ -23,7 +26,7 @@ }, "reauth": { "description": "Withings \ub370\uc774\ud130\ub97c \uacc4\uc18d \uc218\uc2e0\ud558\ub824\uba74 \"{profile}\" \ud504\ub85c\ud544\uc744 \ub2e4\uc2dc \uc778\uc99d\ud574\uc57c \ud569\ub2c8\ub2e4.", - "title": "\ud504\ub85c\ud544 \uc7ac\uc778\uc99d\ud558\uae30" + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d" } } } diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index d8aacd59881..7cc91d32062 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -72,19 +72,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload WLED config entry.""" # Unload entities for this entry/device. - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(entry, component) - for component in WLED_COMPONENTS + unload_ok = all( + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_unload(entry, component) + for component in WLED_COMPONENTS + ) ) ) - # Cleanup - del hass.data[DOMAIN][entry.entry_id] + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: del hass.data[DOMAIN] - return True + return unload_ok def wled_exception_handler(func): diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 4d0f6bf1606..5915447f2f5 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -39,7 +39,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): host = user_input["hostname"].rstrip(".") name, _ = host.rsplit(".") - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { CONF_HOST: user_input["host"], @@ -62,7 +61,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: Optional[ConfigType] = None, prepare: bool = False ) -> Dict[str, Any]: """Config flow handler for WLED.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 source = self.context.get("source") # Request user input, unless we are preparing discovery flow @@ -72,7 +70,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_setup_form() if source == SOURCE_ZEROCONF: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 user_input[CONF_HOST] = self.context.get(CONF_HOST) user_input[CONF_MAC] = self.context.get(CONF_MAC) @@ -93,7 +90,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): title = user_input[CONF_HOST] if source == SOURCE_ZEROCONF: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 title = self.context.get(CONF_NAME) if prepare: @@ -114,7 +110,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): def _show_confirm_dialog(self, errors: Optional[Dict] = None) -> Dict[str, Any]: """Show the confirm dialog to the user.""" - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 name = self.context.get(CONF_NAME) return self.async_show_form( step_id="zeroconf_confirm", diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 527d985a47b..f89cf06a44c 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -442,7 +442,7 @@ async def async_remove_entity( ) -> None: """Remove WLED segment light from Home Assistant.""" entity = current[index] - await entity.async_remove() + await entity.async_remove(force_remove=True) registry = await async_get_entity_registry(coordinator.hass) if entity.entity_id in registry.entities: registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml index 1f1fa1b809d..d6927610a47 100644 --- a/homeassistant/components/wled/services.yaml +++ b/homeassistant/components/wled/services.yaml @@ -1,30 +1,60 @@ effect: - description: Controls the effect settings of WLED + name: Set effect + description: Control the effect settings of WLED. + target: fields: - entity_id: - description: Name of the WLED light entity. - example: "light.wled" effect: + name: Effect description: Name or ID of the WLED light effect. example: "Rainbow" + selector: + text: intensity: + name: Effect intensity description: Intensity of the effect. Number between 0 and 255. example: 100 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider palette: + name: Color palette description: Name or ID of the WLED light palette. example: "Tiamat" + selector: + text: speed: + name: Effect speed description: Speed of the effect. Number between 0 (slow) and 255 (fast). example: 150 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider reverse: - description: Reverse the effect. Either true to reverse or false otherwise. + name: Reverse effect + description: + Reverse the effect. Either true to reverse or false otherwise. + default: false example: false + selector: + boolean: + preset: - description: Calls a preset on the WLED device + name: Set preset + description: Set a preset for the WLED device. + target: fields: - entity_id: - description: Name of the WLED light entity. - example: "light.wled" preset: + name: Preset ID description: ID of the WLED preset example: 6 + selector: + number: + min: -1 + max: 65535 + mode: box diff --git a/homeassistant/components/wled/translations/ko.json b/homeassistant/components/wled/translations/ko.json index 2adb7985fd3..d1945707b6d 100644 --- a/homeassistant/components/wled/translations/ko.json +++ b/homeassistant/components/wled/translations/ko.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "\uc774 WLED \uae30\uae30\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "flow_title": "WLED: {name}", "step": { diff --git a/homeassistant/components/wled/translations/sv.json b/homeassistant/components/wled/translations/sv.json index 3c802a87007..aea858c5bfc 100644 --- a/homeassistant/components/wled/translations/sv.json +++ b/homeassistant/components/wled/translations/sv.json @@ -1,10 +1,19 @@ { "config": { + "abort": { + "already_configured": "Enheten har redan konfigurerats" + }, + "flow_title": "WLED: {name}", "step": { "user": { "data": { "host": "V\u00e4rd eller IP-adress" - } + }, + "description": "St\u00e4ll in din WLED f\u00f6r att integrera med Home Assistant." + }, + "zeroconf_confirm": { + "description": "Vill du l\u00e4gga till WLED-enheten `{name}` till Home Assistant?", + "title": "Uppt\u00e4ckte WLED-enhet" } } } diff --git a/homeassistant/components/wolflink/translations/ru.json b/homeassistant/components/wolflink/translations/ru.json index 841f7b26030..0b105ad922a 100644 --- a/homeassistant/components/wolflink/translations/ru.json +++ b/homeassistant/components/wolflink/translations/ru.json @@ -5,7 +5,7 @@ }, "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": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": { diff --git a/homeassistant/components/wolflink/translations/sensor.ko.json b/homeassistant/components/wolflink/translations/sensor.ko.json index 5b6c33d7231..71fde05a07a 100644 --- a/homeassistant/components/wolflink/translations/sensor.ko.json +++ b/homeassistant/components/wolflink/translations/sensor.ko.json @@ -14,7 +14,8 @@ "auto_off_cool": "\ub0c9\ubc29 \uc790\ub3d9 \uaebc\uc9d0", "auto_on_cool": "\ub0c9\ubc29 \uc790\ub3d9 \ucf1c\uc9d0", "automatik_aus": "\uc790\ub3d9 \uaebc\uc9d0", - "automatik_ein": "\uc790\ub3d9 \ucf1c\uc9d0" + "automatik_ein": "\uc790\ub3d9 \ucf1c\uc9d0", + "permanent": "\uc601\uad6c\uc801" } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.nl.json b/homeassistant/components/wolflink/translations/sensor.nl.json new file mode 100644 index 00000000000..ae205d79aef --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.nl.json @@ -0,0 +1,25 @@ +{ + "state": { + "wolflink__state": { + "frost_warmwasser": "DHW vorst", + "frostschutz": "Vorstbescherming", + "gasdruck": "Gasdruk", + "glt_betrieb": "BMS-modus", + "heizbetrieb": "Verwarmingsmodus", + "heizgerat_mit_speicher": "Boiler met cilinder", + "heizung": "Verwarmen", + "initialisierung": "Initialisatie", + "kalibration": "Kalibratie", + "kalibration_heizbetrieb": "Kalibratie verwarmingsmodus", + "permanent": "Permanent", + "standby": "Stand-by", + "start": "Start", + "storung": "Fout", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Vakantiemodus", + "ventilprufung": "Kleptest", + "warmwasser": "DHW" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbee/__init__.py b/homeassistant/components/xbee/__init__.py index e6175a4dccf..6373cfa7535 100644 --- a/homeassistant/components/xbee/__init__.py +++ b/homeassistant/components/xbee/__init__.py @@ -21,9 +21,9 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = "xbee" +_LOGGER = logging.getLogger(__name__) SIGNAL_XBEE_FRAME_RECEIVED = "xbee_frame_received" @@ -59,7 +59,6 @@ PLATFORM_SCHEMA = vol.Schema( def setup(hass, config): """Set up the connection to the XBee Zigbee device.""" - usb_device = config[DOMAIN].get(CONF_DEVICE, DEFAULT_DEVICE) baud = int(config[DOMAIN].get(CONF_BAUD, DEFAULT_BAUD)) try: diff --git a/homeassistant/components/xbee/binary_sensor.py b/homeassistant/components/xbee/binary_sensor.py index 47c7515ddc7..01095822d1f 100644 --- a/homeassistant/components/xbee/binary_sensor.py +++ b/homeassistant/components/xbee/binary_sensor.py @@ -3,12 +3,8 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity -from . import DOMAIN, PLATFORM_SCHEMA, XBeeDigitalIn, XBeeDigitalInConfig - -CONF_ON_STATE = "on_state" - -DEFAULT_ON_STATE = "high" -STATES = ["high", "low"] +from . import PLATFORM_SCHEMA, XBeeDigitalIn, XBeeDigitalInConfig +from .const import CONF_ON_STATE, DOMAIN, STATES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_ON_STATE): vol.In(STATES)}) diff --git a/homeassistant/components/xbee/const.py b/homeassistant/components/xbee/const.py new file mode 100644 index 00000000000..a77e71e92f5 --- /dev/null +++ b/homeassistant/components/xbee/const.py @@ -0,0 +1,5 @@ +"""Constants for the xbee integration.""" +CONF_ON_STATE = "on_state" +DEFAULT_ON_STATE = "high" +DOMAIN = "xbee" +STATES = ["high", "low"] diff --git a/homeassistant/components/xbee/light.py b/homeassistant/components/xbee/light.py index 76ed8120166..859feee495b 100644 --- a/homeassistant/components/xbee/light.py +++ b/homeassistant/components/xbee/light.py @@ -3,12 +3,8 @@ import voluptuous as vol from homeassistant.components.light import LightEntity -from . import DOMAIN, PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig - -CONF_ON_STATE = "on_state" - -DEFAULT_ON_STATE = "high" -STATES = ["high", "low"] +from . import PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig +from .const import CONF_ON_STATE, DEFAULT_ON_STATE, DOMAIN, STATES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Optional(CONF_ON_STATE, default=DEFAULT_ON_STATE): vol.In(STATES)} diff --git a/homeassistant/components/xbee/sensor.py b/homeassistant/components/xbee/sensor.py index 4a392691032..4d9f9ca518b 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -5,14 +5,13 @@ import logging import voluptuous as vol from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONF_TYPE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity from . import DOMAIN, PLATFORM_SCHEMA, XBeeAnalogIn, XBeeAnalogInConfig, XBeeConfig _LOGGER = logging.getLogger(__name__) -CONF_TYPE = "type" CONF_MAX_VOLTS = "max_volts" DEFAULT_VOLTS = 1.2 diff --git a/homeassistant/components/xbee/switch.py b/homeassistant/components/xbee/switch.py index cdb0d2677c5..b97d9f315d5 100644 --- a/homeassistant/components/xbee/switch.py +++ b/homeassistant/components/xbee/switch.py @@ -3,13 +3,8 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from . import DOMAIN, PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig - -CONF_ON_STATE = "on_state" - -DEFAULT_ON_STATE = "high" - -STATES = ["high", "low"] +from . import PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig +from .const import CONF_ON_STATE, DOMAIN, STATES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_ON_STATE): vol.In(STATES)}) diff --git a/homeassistant/components/xbox/translations/ko.json b/homeassistant/components/xbox/translations/ko.json new file mode 100644 index 00000000000..7314d9e3c5c --- /dev/null +++ b/homeassistant/components/xbox/translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox/translations/nl.json b/homeassistant/components/xbox/translations/nl.json new file mode 100644 index 00000000000..858fd264eaf --- /dev/null +++ b/homeassistant/components/xbox/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen.", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "create_entry": { + "default": "Succesvol geauthenticeerd" + }, + "step": { + "pick_implementation": { + "title": "Kies een authenticatie methode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xfinity/__init__.py b/homeassistant/components/xfinity/__init__.py deleted file mode 100644 index 22e37eccde9..00000000000 --- a/homeassistant/components/xfinity/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xfinity component.""" diff --git a/homeassistant/components/xfinity/device_tracker.py b/homeassistant/components/xfinity/device_tracker.py deleted file mode 100644 index 832c8bb1d5d..00000000000 --- a/homeassistant/components/xfinity/device_tracker.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Support for device tracking via Xfinity Gateways.""" -import logging - -from requests.exceptions import RequestException -import voluptuous as vol -from xfinity_gateway import XfinityGateway - -from homeassistant.components.device_tracker import ( - DOMAIN, - PLATFORM_SCHEMA, - DeviceScanner, -) -from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = "10.0.0.1" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} -) - - -def get_scanner(hass, config): - """Validate the configuration and return an Xfinity Gateway scanner.""" - _LOGGER.warning( - "The Xfinity Gateway has been deprecated and will be removed from " - "Home Assistant in version 0.109. Please remove it from your " - "configuration. " - ) - - gateway = XfinityGateway(config[DOMAIN][CONF_HOST]) - scanner = None - try: - gateway.scan_devices() - scanner = XfinityDeviceScanner(gateway) - except (RequestException, ValueError): - _LOGGER.error( - "Error communicating with Xfinity Gateway. Check host: %s", gateway.host - ) - - return scanner - - -class XfinityDeviceScanner(DeviceScanner): - """This class queries an Xfinity Gateway.""" - - def __init__(self, gateway): - """Initialize the scanner.""" - self.gateway = gateway - - def scan_devices(self): - """Scan for new devices and return a list of found MACs.""" - connected_devices = [] - try: - connected_devices = self.gateway.scan_devices() - except (RequestException, ValueError): - _LOGGER.error("Unable to scan devices. Check connection to gateway") - return connected_devices - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - return self.gateway.get_device_name(device) diff --git a/homeassistant/components/xfinity/manifest.json b/homeassistant/components/xfinity/manifest.json deleted file mode 100644 index 999b77dfb59..00000000000 --- a/homeassistant/components/xfinity/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "xfinity", - "name": "Xfinity Gateway", - "documentation": "https://www.home-assistant.io/integrations/xfinity", - "requirements": ["xfinity-gateway==0.0.4"], - "codeowners": ["@cisasteelersfan"] -} diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index c5b74e68af5..f54c262abba 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_PORT, + CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback @@ -26,7 +27,6 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_INTERFACE, CONF_KEY, - CONF_PROTOCOL, CONF_SID, DEFAULT_DISCOVERY_RETRY, DOMAIN, diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 6bf1aa4f4ee..8028d16f86a 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -14,7 +14,6 @@ from homeassistant.helpers.device_registry import format_mac from .const import ( CONF_INTERFACE, CONF_KEY, - CONF_PROTOCOL, CONF_SID, DEFAULT_DISCOVERY_RETRY, DOMAIN, @@ -181,7 +180,6 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): {CONF_HOST: self.host, CONF_MAC: mac_address} ) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": self.host}}) return await self.async_step_user() diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py index 1cc3b2d4633..11706cdb6fb 100644 --- a/homeassistant/components/xiaomi_aqara/const.py +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -9,7 +9,6 @@ ZEROCONF_GATEWAY = "lumi-gateway" ZEROCONF_ACPARTNER = "lumi-acpartner" CONF_INTERFACE = "interface" -CONF_PROTOCOL = "protocol" CONF_KEY = "key" CONF_SID = "sid" diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index d5fb25d2c3f..a2c8a226c95 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Xiaomi Aqara Gateway", - "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and MAC addresses are left empty, auto-discovery is used", "data": { "interface": "The network interface to use", "host": "[%key:common::config_flow::data::ip%] (optional)", @@ -21,7 +21,7 @@ }, "select": { "title": "Select the Xiaomi Aqara Gateway that you wish to connect", - "description": "Run the setup again if you want to connect aditional gateways", + "description": "Run the setup again if you want to connect additional gateways", "data": { "select_ip": "[%key:common::config_flow::data::ip%]" } diff --git a/homeassistant/components/xiaomi_aqara/translations/en.json b/homeassistant/components/xiaomi_aqara/translations/en.json index 075c8d4a194..d51687a0790 100644 --- a/homeassistant/components/xiaomi_aqara/translations/en.json +++ b/homeassistant/components/xiaomi_aqara/translations/en.json @@ -18,7 +18,7 @@ "data": { "select_ip": "IP Address" }, - "description": "Run the setup again if you want to connect aditional gateways", + "description": "Run the setup again if you want to connect additional gateways", "title": "Select the Xiaomi Aqara Gateway that you wish to connect" }, "settings": { @@ -35,7 +35,7 @@ "interface": "The network interface to use", "mac": "Mac Address (optional)" }, - "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and MAC addresses are left empty, auto-discovery is used", "title": "Xiaomi Aqara Gateway" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/it.json b/homeassistant/components/xiaomi_aqara/translations/it.json index 3299fa092f8..275729e4e81 100644 --- a/homeassistant/components/xiaomi_aqara/translations/it.json +++ b/homeassistant/components/xiaomi_aqara/translations/it.json @@ -18,7 +18,7 @@ "data": { "select_ip": "Indirizzo IP" }, - "description": "Eseguire nuovamente l'installazione se si desidera connettere gateway adizionali", + "description": "Esegui di nuovo la configurazione se desideri connettere gateway aggiuntivi", "title": "Selezionare il Gateway Xiaomi Aqara che si desidera collegare" }, "settings": { @@ -35,7 +35,7 @@ "interface": "L'interfaccia di rete da utilizzare", "mac": "Indirizzo Mac (opzionale)" }, - "description": "Connettiti al tuo Xiaomi Aqara Gateway, se gli indirizzi IP e mac sono lasciati vuoti, verr\u00e0 utilizzato il rilevamento automatico", + "description": "Connettiti al tuo Xiaomi Aqara Gateway, se gli indirizzi IP e MAC sono lasciati vuoti, verr\u00e0 utilizzato il rilevamento automatico", "title": "Xiaomi Aqara Gateway" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/ko.json b/homeassistant/components/xiaomi_aqara/translations/ko.json index 1b4e11c6ea3..7c15bc572e5 100644 --- a/homeassistant/components/xiaomi_aqara/translations/ko.json +++ b/homeassistant/components/xiaomi_aqara/translations/ko.json @@ -2,11 +2,12 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "\uac8c\uc774\ud2b8\uc6e8\uc774 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", "not_xiaomi_aqara": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc544\ub2d9\ub2c8\ub2e4. \ubc1c\uacac\ub41c \uae30\uae30\uac00 \uc54c\ub824\uc9c4 \uac8c\uc774\ud2b8\uc6e8\uc774\uc640 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, "error": { "discovery_error": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \ubc1c\uacac\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. HomeAssistant \ub97c \uc778\ud130\ud398\uc774\uc2a4\ub85c \uc0ac\uc6a9\ud558\ub294 \uae30\uae30\uc758 IP \ub85c \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694.", + "invalid_host": "\ud638\uc2a4\ud2b8\uba85 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc790\uc138\ud55c \uc815\ubcf4\ub294 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem \uc744 \ucc38\uc870\ud574\uc8fc\uc138\uc694", "invalid_interface": "\ub124\ud2b8\uc6cc\ud06c \uc778\ud130\ud398\uc774\uc2a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_key": "\uac8c\uc774\ud2b8\uc6e8\uc774 \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, @@ -14,9 +15,9 @@ "step": { "select": { "data": { - "select_ip": "\uac8c\uc774\ud2b8\uc6e8\uc774 IP" + "select_ip": "IP \uc8fc\uc18c" }, - "description": "\uac8c\uc774\ud2b8\uc6e8\uc774 \uc5f0\uacb0\uc744 \ucd94\uac00\ud558\ub824\uba74 \uc124\uc815\uc744 \ub2e4\uc2dc \uc2e4\ud589\ud574\uc8fc\uc138\uc694", + "description": "\uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \ucd94\uac00 \uc5f0\uacb0\ud558\ub824\uba74 \uc124\uc815\uc744 \ub2e4\uc2dc \uc2e4\ud589\ud574\uc8fc\uc138\uc694", "title": "\uc5f0\uacb0\ud560 Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774 \uc120\ud0dd\ud558\uae30" }, "settings": { @@ -33,7 +34,7 @@ "interface": "\uc0ac\uc6a9\ud560 \ub124\ud2b8\uc6cc\ud06c \uc778\ud130\ud398\uc774\uc2a4", "mac": "Mac \uc8fc\uc18c(\uc120\ud0dd \uc0ac\ud56d)" }, - "description": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud569\ub2c8\ub2e4. IP \ubc0f Mac \uc8fc\uc18c\uac00 \uc124\uc815\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc790\ub3d9 \uac80\uc0c9\uc774 \uc0ac\uc6a9\ub429\ub2c8\ub2e4", + "description": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud569\ub2c8\ub2e4. IP \uc8fc\uc18c \ubc0f MAC \uc8fc\uc18c\ub97c \ube44\uc6cc\ub450\uba74 \uc790\ub3d9 \uac80\uc0c9\uc774 \uc0ac\uc6a9\ub429\ub2c8\ub2e4", "title": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/nl.json b/homeassistant/components/xiaomi_aqara/translations/nl.json index e17b3b572d1..81f984a5a05 100644 --- a/homeassistant/components/xiaomi_aqara/translations/nl.json +++ b/homeassistant/components/xiaomi_aqara/translations/nl.json @@ -1,11 +1,19 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang" + }, + "error": { + "invalid_host": "Ongeldige hostnaam of IP-adres, zie https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_mac": "Ongeldig MAC-adres" }, "flow_title": "Xiaomi Aqara Gateway: {name}", "step": { "select": { + "data": { + "select_ip": "IP-adres" + }, "description": "Voer de installatie opnieuw uit als u extra gateways wilt aansluiten", "title": "Selecteer de Xiaomi Aqara Gateway waarmee u verbinding wilt maken" }, diff --git a/homeassistant/components/xiaomi_aqara/translations/no.json b/homeassistant/components/xiaomi_aqara/translations/no.json index 523e2da898c..5a46d66fcf0 100644 --- a/homeassistant/components/xiaomi_aqara/translations/no.json +++ b/homeassistant/components/xiaomi_aqara/translations/no.json @@ -18,7 +18,7 @@ "data": { "select_ip": "IP adresse" }, - "description": "Kj\u00f8r oppsettet igjen hvis du vil koble til tilleggsportaler", + "description": "Kj\u00f8r oppsettet p\u00e5 nytt hvis du vil koble til flere gatewayer", "title": "Velg Xiaomi Aqara Gateway som du \u00f8nsker \u00e5 koble til" }, "settings": { @@ -35,7 +35,7 @@ "interface": "Nettverksgrensesnittet som skal brukes", "mac": "MAC-adresse (valgfritt)" }, - "description": "Koble til Xiaomi Aqara Gateway, hvis IP- og MAC-adressene er tomme, brukes automatisk oppdagelse", + "description": "Koble til Xiaomi Aqara Gateway, hvis IP- og MAC-adressene blir tomme, brukes automatisk oppdagelse", "title": "" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/ru.json b/homeassistant/components/xiaomi_aqara/translations/ru.json index 96da0a24074..4ede8019a4f 100644 --- a/homeassistant/components/xiaomi_aqara/translations/ru.json +++ b/homeassistant/components/xiaomi_aqara/translations/ru.json @@ -18,7 +18,7 @@ "data": { "select_ip": "IP-\u0430\u0434\u0440\u0435\u0441" }, - "description": "\u0417\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0435\u0449\u0451 \u0440\u0430\u0437, \u0435\u0441\u043b\u0438 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u0448\u043b\u044e\u0437", + "description": "\u0417\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0435\u0449\u0451 \u0440\u0430\u0437, \u0435\u0441\u043b\u0438 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u0448\u043b\u044e\u0437.", "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0448\u043b\u044e\u0437 Xiaomi Aqara" }, "settings": { diff --git a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json index 582aea354c6..5d2d097e832 100644 --- a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json @@ -18,7 +18,7 @@ "data": { "select_ip": "IP \u4f4d\u5740" }, - "description": "\u5982\u679c\u9084\u6709\u5176\u4ed6\u7db2\u95dc\u9700\u8981\u9023\u7dda\uff0c\u8acb\u518d\u57f7\u884c\u4e00\u6b21\u8a2d\u5b9a", + "description": "\u5982\u679c\u9084\u9700\u8981\u9023\u7dda\u81f3\u5176\u4ed6\u7db2\u95dc\uff0c\u8acb\u518d\u57f7\u884c\u4e00\u6b21\u8a2d\u5b9a", "title": "\u9078\u64c7\u6240\u8981\u9023\u7dda\u7684\u5c0f\u7c73 Aqara \u7db2\u95dc" }, "settings": { diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 7ff1ed999c4..a8b32a31576 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -1,13 +1,31 @@ """Support for Xiaomi Miio.""" +from datetime import timedelta +import logging + +from miio.gateway import GatewayException + from homeassistant import config_entries, core from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY -from .const import DOMAIN +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + MODELS_SWITCH, + MODELS_VACUUM, +) from .gateway import ConnectXiaomiGateway +_LOGGER = logging.getLogger(__name__) + GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"] +SWITCH_PLATFORMS = ["switch"] +VACUUM_PLATFORMS = ["vacuum"] async def async_setup(hass: core.HomeAssistant, config: dict): @@ -19,10 +37,13 @@ async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): """Set up the Xiaomi Miio components from a config entry.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: if not await async_setup_gateway_entry(hass, entry): return False + if entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + if not await async_setup_device_entry(hass, entry): + return False return True @@ -46,8 +67,6 @@ async def async_setup_gateway_entry( return False gateway_info = gateway.gateway_info - hass.data[DOMAIN][entry.entry_id] = gateway.gateway_device - gateway_model = f"{gateway_info.model}-{gateway_info.hardware_version}" device_registry = await dr.async_get_registry(hass) @@ -61,9 +80,58 @@ async def async_setup_gateway_entry( sw_version=gateway_info.firmware_version, ) + async def async_update_data(): + """Fetch data from the subdevice.""" + try: + for sub_device in gateway.gateway_device.devices.values(): + await hass.async_add_executor_job(sub_device.update) + except GatewayException as ex: + raise UpdateFailed("Got exception while fetching the state") from ex + + # Create update coordinator + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=10), + ) + + hass.data[DOMAIN][entry.entry_id] = { + CONF_GATEWAY: gateway.gateway_device, + KEY_COORDINATOR: coordinator, + } + for component in GATEWAY_PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) return True + + +async def async_setup_device_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up the Xiaomi Miio device component from a config entry.""" + model = entry.data[CONF_MODEL] + + # Identify platforms to setup + platforms = [] + if model in MODELS_SWITCH: + platforms = SWITCH_PLATFORMS + for vacuum_model in MODELS_VACUUM: + if model.startswith(vacuum_model): + platforms = VACUUM_PLATFORMS + + if not platforms: + return False + + for component in platforms: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 6880202cbd6..26421770771 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) -from .const import DOMAIN +from .const import CONF_GATEWAY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ XIAOMI_STATE_ARMING_VALUE = "oning" async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi Gateway Alarm from a config entry.""" entities = [] - gateway = hass.data[DOMAIN][config_entry.entry_id] + gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] entity = XiaomiGatewayAlarm( gateway, f"{config_entry.title} Alarm", diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 6ebb50cd7ce..d6ee83e9842 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure Xiaomi Miio.""" import logging +from re import search import voluptuous as vol @@ -8,24 +9,28 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac # pylint: disable=unused-import -from .const import DOMAIN -from .gateway import ConnectXiaomiGateway +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MAC, + CONF_MODEL, + DOMAIN, + MODELS_ALL, + MODELS_ALL_DEVICES, + MODELS_GATEWAY, +) +from .device import ConnectXiaomiDevice _LOGGER = logging.getLogger(__name__) -CONF_FLOW_TYPE = "config_flow_device" -CONF_GATEWAY = "gateway" DEFAULT_GATEWAY_NAME = "Xiaomi Gateway" -ZEROCONF_GATEWAY = "lumi-gateway" -ZEROCONF_ACPARTNER = "lumi-acpartner" -GATEWAY_SETTINGS = { +DEVICE_SETTINGS = { vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, } -GATEWAY_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(GATEWAY_SETTINGS) - -CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_GATEWAY, default=False): bool}) +DEVICE_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(DEVICE_SETTINGS) +DEVICE_MODEL_CONFIG = {vol.Optional(CONF_MODEL): vol.In(MODELS_ALL)} class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -37,42 +42,55 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize.""" self.host = None + self.mac = None + + async def async_step_import(self, conf: dict): + """Import a configuration from config.yaml.""" + return await self.async_step_device(user_input=conf) async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - errors = {} - if user_input is not None: - # Check which device needs to be connected. - if user_input[CONF_GATEWAY]: - return await self.async_step_gateway() - - errors["base"] = "no_device_selected" - - return self.async_show_form( - step_id="user", data_schema=CONFIG_SCHEMA, errors=errors - ) + return await self.async_step_device() async def async_step_zeroconf(self, discovery_info): """Handle zeroconf discovery.""" name = discovery_info.get("name") self.host = discovery_info.get("host") - mac_address = discovery_info.get("properties", {}).get("mac") + self.mac = discovery_info.get("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: + self.mac = result.group(0).split("=")[1] - if not name or not self.host or not mac_address: + if not name or not self.host or not self.mac: return self.async_abort(reason="not_xiaomi_miio") + self.mac = format_mac(self.mac) + # Check which device is discovered. - if name.startswith(ZEROCONF_GATEWAY) or name.startswith(ZEROCONF_ACPARTNER): - unique_id = format_mac(mac_address) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + for gateway_model in MODELS_GATEWAY: + if name.startswith(gateway_model.replace(".", "-")): + unique_id = self.mac + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - self.context.update( - {"title_placeholders": {"name": f"Gateway {self.host}"}} - ) + self.context.update( + {"title_placeholders": {"name": f"Gateway {self.host}"}} + ) - return await self.async_step_gateway() + return await self.async_step_device() + for device_model in MODELS_ALL_DEVICES: + if name.startswith(device_model.replace(".", "-")): + unique_id = self.mac + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + self.context.update( + {"title_placeholders": {"name": f"{device_model} {self.host}"}} + ) + + return await self.async_step_device() # Discovered device is not yet supported _LOGGER.debug( @@ -82,42 +100,76 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_miio") - async def async_step_gateway(self, user_input=None): - """Handle a flow initialized by the user to configure a gateway.""" + async def async_step_device(self, user_input=None): + """Handle a flow initialized by the user to configure a xiaomi miio device.""" errors = {} if user_input is not None: token = user_input[CONF_TOKEN] + model = user_input.get(CONF_MODEL) if user_input.get(CONF_HOST): self.host = user_input[CONF_HOST] - # Try to connect to a Xiaomi Gateway. - connect_gateway_class = ConnectXiaomiGateway(self.hass) - await connect_gateway_class.async_connect_gateway(self.host, token) - gateway_info = connect_gateway_class.gateway_info + # Try to connect to a Xiaomi Device. + connect_device_class = ConnectXiaomiDevice(self.hass) + await connect_device_class.async_connect_device(self.host, token) + device_info = connect_device_class.device_info - if gateway_info is not None: - mac = format_mac(gateway_info.mac_address) - unique_id = mac - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_NAME], - data={ - CONF_FLOW_TYPE: CONF_GATEWAY, - CONF_HOST: self.host, - CONF_TOKEN: token, - "model": gateway_info.model, - "mac": mac, - }, - ) + if model is None and device_info is not None: + model = device_info.model - errors["base"] = "cannot_connect" + if model is not None: + if self.mac is None and device_info is not None: + self.mac = format_mac(device_info.mac_address) + + # Setup Gateways + for gateway_model in MODELS_GATEWAY: + if model.startswith(gateway_model): + unique_id = self.mac + await self.async_set_unique_id( + unique_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=DEFAULT_GATEWAY_NAME, + data={ + CONF_FLOW_TYPE: CONF_GATEWAY, + CONF_HOST: self.host, + CONF_TOKEN: token, + CONF_MODEL: model, + CONF_MAC: self.mac, + }, + ) + + # Setup all other Miio Devices + name = user_input.get(CONF_NAME, model) + + for device_model in MODELS_ALL_DEVICES: + if model.startswith(device_model): + unique_id = self.mac + await self.async_set_unique_id( + unique_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: self.host, + CONF_TOKEN: token, + CONF_MODEL: model, + CONF_MAC: self.mac, + }, + ) + errors["base"] = "unknown_device" + else: + errors["base"] = "cannot_connect" if self.host: - schema = vol.Schema(GATEWAY_SETTINGS) + schema = vol.Schema(DEVICE_SETTINGS) else: - schema = GATEWAY_CONFIG + schema = DEVICE_CONFIG - return self.async_show_form( - step_id="gateway", data_schema=schema, errors=errors - ) + if errors: + schema = schema.extend(DEVICE_MODEL_CONFIG) + + return self.async_show_form(step_id="device", data_schema=schema, errors=errors) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 8de68cda97f..d6c39146f6a 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -1,6 +1,33 @@ """Constants for the Xiaomi Miio component.""" DOMAIN = "xiaomi_miio" +CONF_FLOW_TYPE = "config_flow_device" +CONF_GATEWAY = "gateway" +CONF_DEVICE = "device" +CONF_MODEL = "model" +CONF_MAC = "mac" + +KEY_COORDINATOR = "coordinator" + +MODELS_GATEWAY = ["lumi.gateway", "lumi.acpartner"] +MODELS_SWITCH = [ + "chuangmi.plug.v1", + "chuangmi.plug.v3", + "chuangmi.plug.hmi208", + "qmi.powerstrip.v1", + "zimi.powerstrip.v2", + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + "lumi.acpartner.v3", +] +MODELS_VACUUM = ["roborock.vacuum"] + +MODELS_ALL_DEVICES = MODELS_SWITCH + MODELS_VACUUM +MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY + # Fan Services SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py new file mode 100644 index 00000000000..cb91726ecad --- /dev/null +++ b/homeassistant/components/xiaomi_miio/device.py @@ -0,0 +1,91 @@ +"""Code to handle a Xiaomi Device.""" +import logging + +from miio import Device, DeviceException + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import CONF_MAC, CONF_MODEL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConnectXiaomiDevice: + """Class to async connect to a Xiaomi Device.""" + + def __init__(self, hass): + """Initialize the entity.""" + self._hass = hass + self._device = None + self._device_info = None + + @property + def device(self): + """Return the class containing all connections to the device.""" + return self._device + + @property + def device_info(self): + """Return the class containing device info.""" + return self._device_info + + async def async_connect_device(self, host, token): + """Connect to the Xiaomi Device.""" + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + try: + self._device = Device(host, token) + # get the device info + self._device_info = await self._hass.async_add_executor_job( + self._device.info + ) + except DeviceException: + _LOGGER.error( + "DeviceException during setup of xiaomi device with host %s", host + ) + return False + _LOGGER.debug( + "%s %s %s detected", + self._device_info.model, + self._device_info.firmware_version, + self._device_info.hardware_version, + ) + return True + + +class XiaomiMiioEntity(Entity): + """Representation of a base Xiaomi Miio Entity.""" + + def __init__(self, name, device, entry, unique_id): + """Initialize the Xiaomi Miio Device.""" + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._unique_id = unique_id + self._name = name + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self): + """Return the device info.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Xiaomi", + "name": self._name, + "model": self._model, + } + + if self._mac is not None: + device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + + return device_info diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e2a1b3b8143..0d07654e61b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -718,7 +718,20 @@ class XiaomiGenericDevice(FanEntity): return False - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" if speed: # If operation mode was set the device must not be turned on. diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index eb2f4cdf2eb..356b19dc89a 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -4,6 +4,7 @@ import logging from miio import DeviceException, gateway from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -56,16 +57,16 @@ class ConnectXiaomiGateway: return True -class XiaomiGatewayDevice(Entity): +class XiaomiGatewayDevice(CoordinatorEntity, Entity): """Representation of a base Xiaomi Gateway Device.""" - def __init__(self, sub_device, entry): + def __init__(self, coordinator, sub_device, entry): """Initialize the Xiaomi Gateway Device.""" + super().__init__(coordinator) self._sub_device = sub_device self._entry = entry self._unique_id = sub_device.sid self._name = f"{sub_device.name} ({sub_device.sid})" - self._available = False @property def unique_id(self): @@ -88,18 +89,3 @@ class XiaomiGatewayDevice(Entity): "model": self._sub_device.model, "sw_version": self._sub_device.firmware_version, } - - @property - def available(self): - """Return true when state is known.""" - return self._available - - async def async_update(self): - """Fetch state from the sub device.""" - try: - await self.hass.async_add_executor_job(self._sub_device.update) - self._available = True - except gateway.GatewayException as ex: - if self._available: - self._available = False - _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index d1746fcd889..7f168cf0e3e 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -6,14 +6,8 @@ from functools import partial import logging from math import ceil -from miio import ( # pylint: disable=import-error - Ceil, - Device, - DeviceException, - PhilipsBulb, - PhilipsEyecare, - PhilipsMoonlight, -) +from miio import Ceil, DeviceException, PhilipsBulb, PhilipsEyecare, PhilipsMoonlight +from miio import Device # pylint: disable=import-error from miio.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, @@ -37,8 +31,9 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY from .const import ( + CONF_FLOW_TYPE, + CONF_GATEWAY, DOMAIN, SERVICE_EYECARE_MODE_OFF, SERVICE_EYECARE_MODE_ON, @@ -135,7 +130,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: - gateway = hass.data[DOMAIN][config_entry.entry_id] + gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] # Gateway light if gateway.model not in [ GATEWAY_MODEL_AC_V1, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index d20c2dfac1e..821fe164ea9 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -2,13 +2,13 @@ from dataclasses import dataclass import logging -from miio import AirQualityMonitor, DeviceException # pylint: disable=import-error +from miio import AirQualityMonitor # pylint: disable=import-error +from miio import DeviceException from miio.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, GATEWAY_MODEL_EU, - DeviceType, GatewayException, ) import voluptuous as vol @@ -31,8 +31,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY -from .const import DOMAIN +from .const import CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, KEY_COORDINATOR from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) @@ -88,7 +87,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: - gateway = hass.data[DOMAIN][config_entry.entry_id] + gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] # Gateway illuminance sensor if gateway.model not in [ GATEWAY_MODEL_AC_V1, @@ -103,16 +102,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) # Gateway sub devices sub_devices = gateway.devices + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for sub_device in sub_devices.values(): - sensor_variables = None - if sub_device.type == DeviceType.SensorHT: - sensor_variables = ["temperature", "humidity"] - if sub_device.type == DeviceType.AqaraHT: - sensor_variables = ["temperature", "humidity", "pressure"] - if sensor_variables is not None: + sensor_variables = set(sub_device.status) & set(GATEWAY_SENSOR_TYPES) + if sensor_variables: entities.extend( [ - XiaomiGatewaySensor(sub_device, config_entry, variable) + XiaomiGatewaySensor( + coordinator, sub_device, config_entry, variable + ) for variable in sensor_variables ] ) @@ -241,9 +239,9 @@ class XiaomiAirQualityMonitor(Entity): class XiaomiGatewaySensor(XiaomiGatewayDevice): """Representation of a XiaomiGatewaySensor.""" - def __init__(self, sub_device, entry, data_key): + def __init__(self, coordinator, sub_device, entry, data_key): """Initialize the XiaomiSensor.""" - super().__init__(sub_device, entry) + super().__init__(coordinator, sub_device, entry) self._data_key = data_key self._unique_id = f"{sub_device.sid}-{data_key}" self._name = f"{data_key} ({sub_device.sid})".capitalize() @@ -289,9 +287,7 @@ class XiaomiGatewayIlluminanceSensor(Entity): @property def device_info(self): """Return the device info of the gateway.""" - return { - "identifiers": {(DOMAIN, self._gateway_device_id)}, - } + return {"identifiers": {(DOMAIN, self._gateway_device_id)}} @property def name(self): diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 68536de76e5..e3d9376bc31 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -1,31 +1,24 @@ { "config": { - "flow_title": "Xiaomi Miio: {name}", - "step": { - "user": { - "title": "Xiaomi Miio", - "description": "Select to which device you want to connect.", - "data": { - "gateway": "Connect to a Xiaomi Gateway" - } - }, - "gateway": { - "title": "Connect to a Xiaomi Gateway", - "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration.", - "data": { - "host": "[%key:common::config_flow::data::ip%]", - "token": "[%key:common::config_flow::data::api_token%]", - "name": "Name of the Gateway" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_device_selected": "No device selected, please select one device." - }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown_device": "The device model is not known, not able to setup the device using config flow." + }, + "flow_title": "Xiaomi Miio: {name}", + "step": { + "device": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "model": "Device model (Optional)", + "token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + } } } } diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index b9e90cc5c23..3cc95572e6c 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -3,17 +3,13 @@ import asyncio from functools import partial import logging -from miio import ( # pylint: disable=import-error - AirConditioningCompanionV3, - ChuangmiPlug, - Device, - DeviceException, - PowerStrip, -) +from miio import AirConditioningCompanionV3 # pylint: disable=import-error +from miio import ChuangmiPlug, DeviceException, PowerStrip from miio.powerstrip import PowerMode # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -21,23 +17,25 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, DOMAIN, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, SERVICE_SET_WIFI_LED_ON, ) +from .device import XiaomiMiioEntity _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Switch" DATA_KEY = "switch.xiaomi_miio" -CONF_MODEL = "model" MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" MODEL_PLUG_V3 = "chuangmi.plug.v3" @@ -114,119 +112,124 @@ SERVICE_TO_METHOD = { async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the switch from config.""" - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + """Import Miio configuration from YAML.""" + _LOGGER.warning( + "Loading Xiaomi Miio Switch 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, + ) + ) - host = config[CONF_HOST] - token = config[CONF_TOKEN] - name = config[CONF_NAME] - model = config.get(CONF_MODEL) - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the switch from a config entry.""" + entities = [] - devices = [] - unique_id = None + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} - if model is None: - try: - miio_device = Device(host, token) - device_info = await hass.async_add_executor_job(miio_device.info) - model = device_info.model - unique_id = f"{model}-{device_info.mac_address}" - _LOGGER.info( - "%s %s %s detected", - model, - device_info.firmware_version, - device_info.hardware_version, - ) - except DeviceException as ex: - raise PlatformNotReady from ex + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id - if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: - plug = ChuangmiPlug(host, token, model=model) + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - # The device has two switchable channels (mains and a USB port). - # A switch device per channel will be created. - for channel_usb in [True, False]: - device = ChuangMiPlugSwitch(name, plug, model, unique_id, channel_usb) - devices.append(device) + if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: + plug = ChuangmiPlug(host, token, model=model) + + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + if channel_usb: + unique_id_ch = f"{unique_id}-USB" + else: + unique_id_ch = f"{unique_id}-mains" + device = ChuangMiPlugSwitch( + name, plug, config_entry, unique_id_ch, channel_usb + ) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: + plug = PowerStrip(host, token, model=model) + device = XiaomiPowerStripSwitch(name, plug, config_entry, unique_id) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in [ + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + ]: + plug = ChuangmiPlug(host, token, model=model) + device = XiaomiPlugGenericSwitch(name, plug, config_entry, unique_id) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in ["lumi.acpartner.v3"]: + plug = AirConditioningCompanionV3(host, token) + device = XiaomiAirConditioningCompanionSwitch( + name, plug, config_entry, unique_id + ) + entities.append(device) hass.data[DATA_KEY][host] = device - - elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: - plug = PowerStrip(host, token, model=model) - device = XiaomiPowerStripSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - elif model in [ - "chuangmi.plug.m1", - "chuangmi.plug.m3", - "chuangmi.plug.v2", - "chuangmi.plug.hmi205", - "chuangmi.plug.hmi206", - ]: - plug = ChuangmiPlug(host, token, model=model) - device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - elif model in ["lumi.acpartner.v3"]: - plug = AirConditioningCompanionV3(host, token) - device = XiaomiAirConditioningCompanionSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - else: - _LOGGER.error( - "Unsupported device found! Please create an issue at " - "https://github.com/rytilahti/python-miio/issues " - "and provide the following data: %s", - model, - ) - return False - - async_add_entities(devices, update_before_add=True) - - async def async_service_handler(service): - """Map services to methods on XiaomiPlugGenericSwitch.""" - method = SERVICE_TO_METHOD.get(service.service) - params = { - key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID - } - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - devices = [ - device - for device in hass.data[DATA_KEY].values() - if device.entity_id in entity_ids - ] else: - devices = hass.data[DATA_KEY].values() + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/rytilahti/python-miio/issues " + "and provide the following data: %s", + model, + ) - update_tasks = [] - for device in devices: - if not hasattr(device, method["method"]): - continue - await getattr(device, method["method"])(**params) - update_tasks.append(device.async_update_ha_state(True)) + async def async_service_handler(service): + """Map services to methods on XiaomiPlugGenericSwitch.""" + method = SERVICE_TO_METHOD.get(service.service) + params = { + key: value + for key, value in service.data.items() + if key != ATTR_ENTITY_ID + } + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + devices = [ + device + for device in hass.data[DATA_KEY].values() + if device.entity_id in entity_ids + ] + else: + devices = hass.data[DATA_KEY].values() - if update_tasks: - await asyncio.wait(update_tasks) + update_tasks = [] + for device in devices: + if not hasattr(device, method["method"]): + continue + await getattr(device, method["method"])(**params) + update_tasks.append(device.async_update_ha_state(True)) - for plug_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, plug_service, async_service_handler, schema=schema - ) + if update_tasks: + await asyncio.wait(update_tasks) + + for plug_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema + ) + + async_add_entities(entities, update_before_add=True) -class XiaomiPlugGenericSwitch(SwitchEntity): +class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, plug, model, unique_id): + def __init__(self, name, device, entry, unique_id): """Initialize the plug switch.""" - self._name = name - self._plug = plug - self._model = model - self._unique_id = unique_id + super().__init__(name, device, entry, unique_id) self._icon = "mdi:power-socket" self._available = False @@ -235,16 +238,6 @@ class XiaomiPlugGenericSwitch(SwitchEntity): self._device_features = FEATURE_FLAGS_GENERIC self._skip_update = False - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - @property def icon(self): """Return the icon to use for device if any.""" @@ -288,7 +281,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): async def async_turn_on(self, **kwargs): """Turn the plug on.""" - result = await self._try_command("Turning the plug on failed.", self._plug.on) + result = await self._try_command("Turning the plug on failed", self._device.on) if result: self._state = True @@ -296,7 +289,9 @@ class XiaomiPlugGenericSwitch(SwitchEntity): async def async_turn_off(self, **kwargs): """Turn the plug off.""" - result = await self._try_command("Turning the plug off failed.", self._plug.off) + result = await self._try_command( + "Turning the plug off failed", self._device.off + ) if result: self._state = False @@ -310,7 +305,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -328,7 +323,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Turning the wifi led on failed.", self._plug.set_wifi_led, True + "Turning the wifi led on failed", self._device.set_wifi_led, True ) async def async_set_wifi_led_off(self): @@ -337,7 +332,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Turning the wifi led off failed.", self._plug.set_wifi_led, False + "Turning the wifi led off failed", self._device.set_wifi_led, False ) async def async_set_power_price(self, price: int): @@ -346,8 +341,8 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Setting the power price of the power strip failed.", - self._plug.set_power_price, + "Setting the power price of the power strip failed", + self._device.set_power_price, price, ) @@ -383,7 +378,7 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -415,8 +410,8 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return await self._try_command( - "Setting the power mode of the power strip failed.", - self._plug.set_power_mode, + "Setting the power mode of the power strip failed", + self._device.set_power_mode, PowerMode(mode), ) @@ -424,14 +419,14 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Representation of a Chuang Mi Plug V1 and V3.""" - def __init__(self, name, plug, model, unique_id, channel_usb): + def __init__(self, name, plug, entry, unique_id, channel_usb): """Initialize the plug switch.""" name = f"{name} USB" if channel_usb else name if unique_id is not None and channel_usb: unique_id = f"{unique_id}-usb" - super().__init__(name, plug, model, unique_id) + super().__init__(name, plug, entry, unique_id) self._channel_usb = channel_usb if self._model == MODEL_PLUG_V3: @@ -444,11 +439,11 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Turn a channel on.""" if self._channel_usb: result = await self._try_command( - "Turning the plug on failed.", self._plug.usb_on + "Turning the plug on failed", self._device.usb_on ) else: result = await self._try_command( - "Turning the plug on failed.", self._plug.on + "Turning the plug on failed", self._device.on ) if result: @@ -459,11 +454,11 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Turn a channel off.""" if self._channel_usb: result = await self._try_command( - "Turning the plug on failed.", self._plug.usb_off + "Turning the plug off failed", self._device.usb_off ) else: result = await self._try_command( - "Turning the plug on failed.", self._plug.off + "Turning the plug off failed", self._device.off ) if result: @@ -478,7 +473,7 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -513,7 +508,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): async def async_turn_on(self, **kwargs): """Turn the socket on.""" result = await self._try_command( - "Turning the socket on failed.", self._plug.socket_on + "Turning the socket on failed", self._device.socket_on ) if result: @@ -523,7 +518,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): async def async_turn_off(self, **kwargs): """Turn the socket off.""" result = await self._try_command( - "Turning the socket off failed.", self._plug.socket_off + "Turning the socket off failed", self._device.socket_off ) if result: @@ -538,7 +533,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/xiaomi_miio/translations/ca.json b/homeassistant/components/xiaomi_miio/translations/ca.json index 5183157371b..170d14fc6dc 100644 --- a/homeassistant/components/xiaomi_miio/translations/ca.json +++ b/homeassistant/components/xiaomi_miio/translations/ca.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", - "no_device_selected": "No hi ha cap dispositiu seleccionat, selecciona'n un." + "no_device_selected": "No hi ha cap dispositiu seleccionat, selecciona'n un.", + "unknown_device": "No es reconeix el model del dispositiu, no es pot configurar el dispositiu mitjan\u00e7ant el flux de configuraci\u00f3." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "Adre\u00e7a IP", + "model": "Model del dispositiu (opcional)", + "name": "Nom del dispositiu", + "token": "Token d'API" + }, + "description": "Necessitar\u00e0s el Token d'API de 32 car\u00e0cters, consulta les instruccions a https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token. Tingues en compte que aquest Token d'API \u00e9s diferent a la clau utilitzada per la integraci\u00f3 Xiaomi Aqara.", + "title": "Connexi\u00f3 amb un dispositiu Xiaomi Miio o una passarel\u00b7la de Xiaomi" + }, "gateway": { "data": { "host": "Adre\u00e7a IP", diff --git a/homeassistant/components/xiaomi_miio/translations/cs.json b/homeassistant/components/xiaomi_miio/translations/cs.json index 91c30a69e54..ec275b93330 100644 --- a/homeassistant/components/xiaomi_miio/translations/cs.json +++ b/homeassistant/components/xiaomi_miio/translations/cs.json @@ -10,6 +10,12 @@ }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP adresa", + "token": "API token" + } + }, "gateway": { "data": { "host": "IP adresa", diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index d56a81e14d4..7cf11a1085e 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -10,6 +10,13 @@ }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP-Adresse", + "name": "Name des Ger\u00e4ts", + "token": "API-Token" + } + }, "gateway": { "data": { "host": "IP-Adresse", diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index 4d39a6d1137..3d893ade2f0 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "Failed to connect", - "no_device_selected": "No device selected, please select one device." + "no_device_selected": "No device selected, please select one device.", + "unknown_device": "The device model is not known, not able to setup the device using config flow." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP Address", + "model": "Device model (Optional)", + "name": "Name of the device", + "token": "API Token" + }, + "description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this API Token is different from the key used by the Xiaomi Aqara integration.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, "gateway": { "data": { "host": "IP Address", diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 46fb93012ad..60a989ade0d 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -6,10 +6,19 @@ }, "error": { "cannot_connect": "No se pudo conectar", - "no_device_selected": "No se ha seleccionado ning\u00fan dispositivo, por favor, seleccione un dispositivo." + "no_device_selected": "No se ha seleccionado ning\u00fan dispositivo, por favor, seleccione un dispositivo.", + "unknown_device": "No se conoce el modelo del dispositivo, no se puede configurar el dispositivo mediante el flujo de configuraci\u00f3n." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "model": "Modelo de dispositivo (opcional)", + "name": "Nombre del dispositivo" + }, + "description": "Necesitar\u00e1 la clave de 32 caracteres Token API, consulte https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token para obtener instrucciones. Tenga en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", + "title": "Con\u00e9ctese a un dispositivo Xiaomi Miio o Xiaomi Gateway" + }, "gateway": { "data": { "host": "Direcci\u00f3n IP", diff --git a/homeassistant/components/xiaomi_miio/translations/et.json b/homeassistant/components/xiaomi_miio/translations/et.json index f6bd3218e14..a290f80ad31 100644 --- a/homeassistant/components/xiaomi_miio/translations/et.json +++ b/homeassistant/components/xiaomi_miio/translations/et.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "\u00dchendus nurjus", - "no_device_selected": "Seadmeid pole valitud, vali \u00fcks seade." + "no_device_selected": "Seadmeid pole valitud, vali \u00fcks seade.", + "unknown_device": "Seadme mudel pole teada, seadet ei saa seadistamisvoo abil seadistada." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP-aadress", + "model": "Seadme mudel (valikuline)", + "name": "Seadme nimi", + "token": "API v\u00f5ti" + }, + "description": "Vaja on 32 t\u00e4hem\u00e4rgilist v\u00f5tit API v\u00f5ti , juhiste saamiseks vaata https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token. Pane t\u00e4hele, et see v\u00f5ti API v\u00f5ti erineb Xiaomi Aqara sidumises kasutatavast v\u00f5tmest.", + "title": "\u00dchenda Xiaomi Miio seade v\u00f5i Xiaomi Gateway" + }, "gateway": { "data": { "host": "IP aadress", diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 84849041c8c..30def127e7a 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "no_device_selected": "Aucun appareil s\u00e9lectionn\u00e9, veuillez s\u00e9lectionner un appareil." + "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." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "Adresse IP", + "model": "Mod\u00e8le d'appareil (facultatif)", + "name": "Nom de l'appareil", + "token": "Jeton d'API" + }, + "description": "Vous aurez besoin des 32 caract\u00e8res Jeton d'API , voir https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token pour les instructions. Veuillez noter que cette Jeton d'API est diff\u00e9rente de la cl\u00e9 utilis\u00e9e par l'int\u00e9gration Xiaomi Aqara.", + "title": "Connectez-vous \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, "gateway": { "data": { "host": "Adresse IP", diff --git a/homeassistant/components/xiaomi_miio/translations/it.json b/homeassistant/components/xiaomi_miio/translations/it.json index cbfc2d60621..aa48ba7cfa8 100644 --- a/homeassistant/components/xiaomi_miio/translations/it.json +++ b/homeassistant/components/xiaomi_miio/translations/it.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "Impossibile connettersi", - "no_device_selected": "Nessun dispositivo selezionato, selezionare un dispositivo." + "no_device_selected": "Nessun dispositivo selezionato, selezionare un dispositivo.", + "unknown_device": "Il modello del dispositivo non \u00e8 noto, non \u00e8 possibile configurare il dispositivo utilizzando il flusso di configurazione." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "Indirizzo IP", + "model": "Modello del dispositivo (opzionale)", + "name": "Nome del dispositivo", + "token": "Token API" + }, + "description": "Avrai bisogno dei 32 caratteri Token API , vedi https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per istruzioni. Tieni presente che questa Token API \u00e8 diversa dalla chiave utilizzata dall'integrazione Xiaomi Aqara.", + "title": "Connettiti a un dispositivo Xiaomi Miio o Xiaomi Gateway" + }, "gateway": { "data": { "host": "Indirizzo IP", diff --git a/homeassistant/components/xiaomi_miio/translations/ko.json b/homeassistant/components/xiaomi_miio/translations/ko.json index 52f1ed960d2..7e594fde247 100644 --- a/homeassistant/components/xiaomi_miio/translations/ko.json +++ b/homeassistant/components/xiaomi_miio/translations/ko.json @@ -2,20 +2,27 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "already_in_progress": "Xiaomi Miio \uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4." + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4" }, "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "no_device_selected": "\uc120\ud0dd\ub41c \uae30\uae30\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP \uc8fc\uc18c", + "token": "API \ud1a0\ud070" + } + }, "gateway": { "data": { "host": "IP \uc8fc\uc18c", "name": "\uac8c\uc774\ud2b8\uc6e8\uc774 \uc774\ub984", "token": "API \ud1a0\ud070" }, - "description": "32 \ubb38\uc790\uc758 API \ud1a0\ud070\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694. \uc774 \ud1a0\ud070\uc740 Xiaomi Aqara \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc5d0\uc11c \uc0ac\uc6a9\ub418\ub294 \ud0a4\uc640 \ub2e4\ub985\ub2c8\ub2e4.", + "description": "32 \uac1c\uc758 \ubb38\uc790\uc5f4\ub85c \uad6c\uc131\ub41c API \ud1a0\ud070\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \uc790\uc138\ud55c \uc815\ubcf4\ub294 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \uc744 \ucc38\uc870\ud574\uc8fc\uc138\uc694. \ucc38\uace0\ub85c \uc774 API \ud1a0\ud070\uc740 Xiaomi Aqara \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc5d0\uc11c \uc0ac\uc6a9\ub418\ub294 \ud0a4\uc640 \ub2e4\ub985\ub2c8\ub2e4.", "title": "Xiaomi \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\uae30" }, "user": { diff --git a/homeassistant/components/xiaomi_miio/translations/nl.json b/homeassistant/components/xiaomi_miio/translations/nl.json index eea72c1c4c0..66209e61ee6 100644 --- a/homeassistant/components/xiaomi_miio/translations/nl.json +++ b/homeassistant/components/xiaomi_miio/translations/nl.json @@ -5,9 +5,19 @@ "already_in_progress": "De configuratiestroom voor dit Xiaomi Miio-apparaat is al bezig." }, "error": { + "cannot_connect": "Kan geen verbinding maken", "no_device_selected": "Geen apparaat geselecteerd, selecteer 1 apparaat alstublieft" }, + "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP-adres", + "model": "Apparaatmodel (Optioneel)", + "name": "Naam van het apparaat", + "token": "API-token" + } + }, "gateway": { "data": { "host": "IP-adres", diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index c7128900fc7..0a6cf433d87 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "Tilkobling mislyktes", - "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet." + "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet.", + "unknown_device": "Enhetsmodellen er ikke kjent, kan ikke konfigurere enheten ved hjelp av konfigurasjonsflyt." }, "flow_title": "", "step": { + "device": { + "data": { + "host": "IP adresse", + "model": "Enhetsmodell (valgfritt)", + "name": "Navnet p\u00e5 enheten", + "token": "API-token" + }, + "description": "Du trenger 32 tegn API-token , se https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token for instruksjoner. V\u00e6r oppmerksom p\u00e5 at denne API-token er forskjellig fra n\u00f8kkelen som brukes av Xiaomi Aqara-integrasjonen.", + "title": "Koble til en Xiaomi Miio-enhet eller Xiaomi Gateway" + }, "gateway": { "data": { "host": "IP adresse", diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index 82f7f958905..80528b71370 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "no_device_selected": "Nie wybrano \u017cadnego urz\u0105dzenia, wybierz jedno urz\u0105dzenie" + "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." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "Adres IP", + "model": "Model urz\u0105dzenia (opcjonalnie)", + "name": "Nazwa urz\u0105dzenia", + "token": "Token API" + }, + "description": "B\u0119dziesz potrzebowa\u0107 tokenu API (32 znaki), odwied\u017a https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token, aby uzyska\u0107 instrukcje. Zauwa\u017c i\u017c jest to inny token ni\u017c w integracji Xiaomi Aqara.", + "title": "Po\u0142\u0105czenie z bramk\u0105 Xiaomi b\u0105d\u017a innym urz\u0105dzeniem Xiaomi Miio" + }, "gateway": { "data": { "host": "Adres IP", diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json index be02fd14f3e..5c5064ac347 100644 --- a/homeassistant/components/xiaomi_miio/translations/ru.json +++ b/homeassistant/components/xiaomi_miio/translations/ru.json @@ -6,10 +6,21 @@ }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "no_device_selected": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u043e \u0438\u0437 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432." + "no_device_selected": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u043e \u0438\u0437 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", + "unknown_device": "\u041c\u043e\u0434\u0435\u043b\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430, \u043d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043c\u0430\u0441\u0442\u0435\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "device": { + "data": { + "host": "IP-\u0430\u0434\u0440\u0435\u0441", + "model": "\u041c\u043e\u0434\u0435\u043b\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "token": "\u0422\u043e\u043a\u0435\u043d API" + }, + "description": "\u0414\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f 32-\u0445 \u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u0422\u043e\u043a\u0435\u043d API. \u041e \u0442\u043e\u043c, \u043a\u0430\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0442\u043e\u043a\u0435\u043d, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0443\u0437\u043d\u0430\u0442\u044c \u0437\u0434\u0435\u0441\u044c: \nhttps://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token.\n\u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435 \u0432\u043d\u0438\u043c\u0430\u043d\u0438\u0435, \u0447\u0442\u043e \u044d\u0442\u043e\u0442 \u0442\u043e\u043a\u0435\u043d \u043e\u0442\u043b\u0438\u0447\u0430\u0435\u0442\u0441\u044f \u043e\u0442 \u043a\u043b\u044e\u0447\u0430, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0433\u043e \u043f\u0440\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Xiaomi Aqara.", + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Xiaomi Miio \u0438\u043b\u0438 \u0448\u043b\u044e\u0437\u0443 Xiaomi" + }, "gateway": { "data": { "host": "IP-\u0430\u0434\u0440\u0435\u0441", diff --git a/homeassistant/components/xiaomi_miio/translations/tr.json b/homeassistant/components/xiaomi_miio/translations/tr.json index 46a6493ab3a..3dbf08bd6f1 100644 --- a/homeassistant/components/xiaomi_miio/translations/tr.json +++ b/homeassistant/components/xiaomi_miio/translations/tr.json @@ -9,6 +9,11 @@ "no_device_selected": "Cihaz se\u00e7ilmedi, l\u00fctfen bir cihaz se\u00e7in." }, "step": { + "device": { + "data": { + "name": "Cihaz\u0131n ad\u0131" + } + }, "gateway": { "data": { "host": "\u0130p Adresi", diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json index 95499fb7b82..3b0a89b7485 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json @@ -6,17 +6,28 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "no_device_selected": "\u672a\u9078\u64c7\u88dd\u7f6e\uff0c\u8acb\u9078\u64c7\u4e00\u9805\u88dd\u7f6e\u3002" + "no_device_selected": "\u672a\u9078\u64c7\u88dd\u7f6e\uff0c\u8acb\u9078\u64c7\u4e00\u9805\u88dd\u7f6e\u3002", + "unknown_device": "\u88dd\u7f6e\u578b\u865f\u672a\u77e5\uff0c\u7121\u6cd5\u4f7f\u7528\u8a2d\u5b9a\u6d41\u7a0b\u3002" }, "flow_title": "Xiaomi Miio\uff1a{name}", "step": { + "device": { + "data": { + "host": "IP \u4f4d\u5740", + "model": "\u88dd\u7f6e\u578b\u865f\uff08\u9078\u9805\uff09", + "name": "\u88dd\u7f6e\u540d\u7a31", + "token": "API \u6b0a\u6756" + }, + "description": "\u5c07\u9700\u8981\u8f38\u5165 32 \u4f4d\u5b57\u5143 API \u6b0a\u6756\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u4ee5\u7372\u5f97\u7372\u53d6\u6b0a\u6756\u7684\u6559\u5b78\u3002\u8acb\u6ce8\u610f\uff1a\u6b64API \u6b0a\u6756\u8207 Xiaomi Aqara \u6574\u5408\u6240\u4f7f\u7528\u4e4b\u6b0a\u6756\u4e0d\u540c\u3002", + "title": "\u9023\u7dda\u81f3\u5c0f\u7c73 MIIO \u88dd\u7f6e\u6216\u5c0f\u7c73\u7db2\u95dc" + }, "gateway": { "data": { "host": "IP \u4f4d\u5740", "name": "\u7db2\u95dc\u540d\u7a31", - "token": "API \u5bc6\u9470" + "token": "API \u6b0a\u6756" }, - "description": "\u5c07\u9700\u8981\u8f38\u5165 32 \u4f4d\u5b57\u5143 API \u5bc6\u9470\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u4ee5\u7372\u5f97\u7372\u53d6\u5bc6\u9470\u7684\u6559\u5b78\u3002\u8acb\u6ce8\u610f\uff1a\u6b64API \u5bc6\u9470\u8207 Xiaomi Aqara \u6574\u5408\u6240\u4f7f\u7528\u4e4b\u5bc6\u9470\u4e0d\u540c\u3002", + "description": "\u5c07\u9700\u8981\u8f38\u5165 32 \u4f4d\u5b57\u5143 API \u6b0a\u6756\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u4ee5\u7372\u5f97\u7372\u53d6\u6b0a\u6756\u7684\u6559\u5b78\u3002\u8acb\u6ce8\u610f\uff1a\u6b64API \u6b0a\u6756\u8207 Xiaomi Aqara \u6574\u5408\u6240\u4f7f\u7528\u4e4b\u6b0a\u6756\u4e0d\u540c\u3002", "title": "\u9023\u7dda\u81f3\u5c0f\u7c73\u7db2\u95dc" }, "user": { diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index ab76d14a69a..7bdbfca7bc9 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -26,11 +26,15 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.util.dt import as_utc from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + DOMAIN, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -39,11 +43,11 @@ from .const import ( SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL, ) +from .device import XiaomiMiioEntity _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Vacuum cleaner" -DATA_KEY = "vacuum.xiaomi_miio" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -116,110 +120,124 @@ STATE_CODE_TO_STATE = { async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Xiaomi vacuum cleaner robot platform.""" - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} - - host = config[CONF_HOST] - token = config[CONF_TOKEN] - name = config[CONF_NAME] - - # Create handler - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - vacuum = Vacuum(host, token) - - mirobo = MiroboVacuum(name, vacuum) - hass.data[DATA_KEY][host] = mirobo - - async_add_entities([mirobo], update_before_add=True) - - platform = entity_platform.current_platform.get() - - platform.async_register_entity_service( - SERVICE_START_REMOTE_CONTROL, - {}, - MiroboVacuum.async_remote_control_start.__name__, + """Import Miio configuration from YAML.""" + _LOGGER.warning( + "Loading Xiaomi Miio Vacuum via platform setup is deprecated. Please remove it from your configuration." ) - - platform.async_register_entity_service( - SERVICE_STOP_REMOTE_CONTROL, - {}, - MiroboVacuum.async_remote_control_stop.__name__, - ) - - platform.async_register_entity_service( - SERVICE_MOVE_REMOTE_CONTROL, - { - vol.Optional(ATTR_RC_VELOCITY): vol.All( - vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29) - ), - vol.Optional(ATTR_RC_ROTATION): vol.All( - vol.Coerce(int), vol.Clamp(min=-179, max=179) - ), - vol.Optional(ATTR_RC_DURATION): cv.positive_int, - }, - MiroboVacuum.async_remote_control_move.__name__, - ) - - platform.async_register_entity_service( - SERVICE_MOVE_REMOTE_CONTROL_STEP, - { - vol.Optional(ATTR_RC_VELOCITY): vol.All( - vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29) - ), - vol.Optional(ATTR_RC_ROTATION): vol.All( - vol.Coerce(int), vol.Clamp(min=-179, max=179) - ), - vol.Optional(ATTR_RC_DURATION): cv.positive_int, - }, - MiroboVacuum.async_remote_control_move_step.__name__, - ) - - platform.async_register_entity_service( - SERVICE_CLEAN_ZONE, - { - vol.Required(ATTR_ZONE_ARRAY): vol.All( - list, - [ - vol.ExactSequence( - [ - vol.Coerce(int), - vol.Coerce(int), - vol.Coerce(int), - vol.Coerce(int), - ] - ) - ], - ), - vol.Required(ATTR_ZONE_REPEATER): vol.All( - vol.Coerce(int), vol.Clamp(min=1, max=3) - ), - }, - MiroboVacuum.async_clean_zone.__name__, - ) - - platform.async_register_entity_service( - SERVICE_GOTO, - { - vol.Required("x_coord"): vol.Coerce(int), - vol.Required("y_coord"): vol.Coerce(int), - }, - MiroboVacuum.async_goto.__name__, - ) - platform.async_register_entity_service( - SERVICE_CLEAN_SEGMENT, - {vol.Required("segments"): vol.Any(vol.Coerce(int), [vol.Coerce(int)])}, - MiroboVacuum.async_clean_segment.__name__, + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) -class MiroboVacuum(StateVacuumEntity): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Xiaomi vacuum cleaner robot from a config entry.""" + entities = [] + + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + name = config_entry.title + unique_id = config_entry.unique_id + + # Create handler + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + vacuum = Vacuum(host, token) + + mirobo = MiroboVacuum(name, vacuum, config_entry, unique_id) + entities.append(mirobo) + + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_START_REMOTE_CONTROL, + {}, + MiroboVacuum.async_remote_control_start.__name__, + ) + + platform.async_register_entity_service( + SERVICE_STOP_REMOTE_CONTROL, + {}, + MiroboVacuum.async_remote_control_stop.__name__, + ) + + platform.async_register_entity_service( + SERVICE_MOVE_REMOTE_CONTROL, + { + vol.Optional(ATTR_RC_VELOCITY): vol.All( + vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29) + ), + vol.Optional(ATTR_RC_ROTATION): vol.All( + vol.Coerce(int), vol.Clamp(min=-179, max=179) + ), + vol.Optional(ATTR_RC_DURATION): cv.positive_int, + }, + MiroboVacuum.async_remote_control_move.__name__, + ) + + platform.async_register_entity_service( + SERVICE_MOVE_REMOTE_CONTROL_STEP, + { + vol.Optional(ATTR_RC_VELOCITY): vol.All( + vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29) + ), + vol.Optional(ATTR_RC_ROTATION): vol.All( + vol.Coerce(int), vol.Clamp(min=-179, max=179) + ), + vol.Optional(ATTR_RC_DURATION): cv.positive_int, + }, + MiroboVacuum.async_remote_control_move_step.__name__, + ) + + platform.async_register_entity_service( + SERVICE_CLEAN_ZONE, + { + vol.Required(ATTR_ZONE_ARRAY): vol.All( + list, + [ + vol.ExactSequence( + [ + vol.Coerce(int), + vol.Coerce(int), + vol.Coerce(int), + vol.Coerce(int), + ] + ) + ], + ), + vol.Required(ATTR_ZONE_REPEATER): vol.All( + vol.Coerce(int), vol.Clamp(min=1, max=3) + ), + }, + MiroboVacuum.async_clean_zone.__name__, + ) + + platform.async_register_entity_service( + SERVICE_GOTO, + { + vol.Required("x_coord"): vol.Coerce(int), + vol.Required("y_coord"): vol.Coerce(int), + }, + MiroboVacuum.async_goto.__name__, + ) + platform.async_register_entity_service( + SERVICE_CLEAN_SEGMENT, + {vol.Required("segments"): vol.Any(vol.Coerce(int), [vol.Coerce(int)])}, + MiroboVacuum.async_clean_segment.__name__, + ) + + async_add_entities(entities, update_before_add=True) + + +class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): """Representation of a Xiaomi Vacuum cleaner robot.""" - def __init__(self, name, vacuum): + def __init__(self, name, device, entry, unique_id): """Initialize the Xiaomi vacuum cleaner robot handler.""" - self._name = name - self._vacuum = vacuum + super().__init__(name, device, entry, unique_id) self.vacuum_state = None self._available = False @@ -233,11 +251,6 @@ class MiroboVacuum(StateVacuumEntity): self._timers = None - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def state(self): """Return the status of the vacuum cleaner.""" @@ -364,16 +377,16 @@ class MiroboVacuum(StateVacuumEntity): async def async_start(self): """Start or resume the cleaning task.""" await self._try_command( - "Unable to start the vacuum: %s", self._vacuum.resume_or_start + "Unable to start the vacuum: %s", self._device.resume_or_start ) async def async_pause(self): """Pause the cleaning task.""" - await self._try_command("Unable to set start/pause: %s", self._vacuum.pause) + await self._try_command("Unable to set start/pause: %s", self._device.pause) async def async_stop(self, **kwargs): """Stop the vacuum cleaner.""" - await self._try_command("Unable to stop: %s", self._vacuum.stop) + await self._try_command("Unable to stop: %s", self._device.stop) async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" @@ -390,28 +403,28 @@ class MiroboVacuum(StateVacuumEntity): ) return await self._try_command( - "Unable to set fan speed: %s", self._vacuum.set_fan_speed, fan_speed + "Unable to set fan speed: %s", self._device.set_fan_speed, fan_speed ) async def async_return_to_base(self, **kwargs): """Set the vacuum cleaner to return to the dock.""" - await self._try_command("Unable to return home: %s", self._vacuum.home) + await self._try_command("Unable to return home: %s", self._device.home) async def async_clean_spot(self, **kwargs): """Perform a spot clean-up.""" await self._try_command( - "Unable to start the vacuum for a spot clean-up: %s", self._vacuum.spot + "Unable to start the vacuum for a spot clean-up: %s", self._device.spot ) async def async_locate(self, **kwargs): """Locate the vacuum cleaner.""" - await self._try_command("Unable to locate the botvac: %s", self._vacuum.find) + await self._try_command("Unable to locate the botvac: %s", self._device.find) async def async_send_command(self, command, params=None, **kwargs): """Send raw command.""" await self._try_command( "Unable to send command to the vacuum: %s", - self._vacuum.raw_command, + self._device.raw_command, command, params, ) @@ -419,13 +432,13 @@ class MiroboVacuum(StateVacuumEntity): async def async_remote_control_start(self): """Start remote control mode.""" await self._try_command( - "Unable to start remote control the vacuum: %s", self._vacuum.manual_start + "Unable to start remote control the vacuum: %s", self._device.manual_start ) async def async_remote_control_stop(self): """Stop remote control mode.""" await self._try_command( - "Unable to stop remote control the vacuum: %s", self._vacuum.manual_stop + "Unable to stop remote control the vacuum: %s", self._device.manual_stop ) async def async_remote_control_move( @@ -434,7 +447,7 @@ class MiroboVacuum(StateVacuumEntity): """Move vacuum with remote control mode.""" await self._try_command( "Unable to move with remote control the vacuum: %s", - self._vacuum.manual_control, + self._device.manual_control, velocity=velocity, rotation=rotation, duration=duration, @@ -446,7 +459,7 @@ class MiroboVacuum(StateVacuumEntity): """Move vacuum one step with remote control mode.""" await self._try_command( "Unable to remote control the vacuum: %s", - self._vacuum.manual_control_once, + self._device.manual_control_once, velocity=velocity, rotation=rotation, duration=duration, @@ -456,7 +469,7 @@ class MiroboVacuum(StateVacuumEntity): """Goto the specified coordinates.""" await self._try_command( "Unable to send the vacuum cleaner to the specified coordinates: %s", - self._vacuum.goto, + self._device.goto, x_coord=x_coord, y_coord=y_coord, ) @@ -468,23 +481,23 @@ class MiroboVacuum(StateVacuumEntity): await self._try_command( "Unable to start cleaning of the specified segments: %s", - self._vacuum.segment_clean, + self._device.segment_clean, segments=segments, ) def update(self): """Fetch state from the device.""" try: - state = self._vacuum.status() + state = self._device.status() self.vacuum_state = state - self._fan_speeds = self._vacuum.fan_speed_presets() + self._fan_speeds = self._device.fan_speed_presets() self._fan_speeds_reverse = {v: k for k, v in self._fan_speeds.items()} - self.consumable_state = self._vacuum.consumable_status() - self.clean_history = self._vacuum.clean_history() - self.last_clean = self._vacuum.last_clean_details() - self.dnd_state = self._vacuum.dnd_status() + self.consumable_state = self._device.consumable_status() + self.clean_history = self._device.clean_history() + self.last_clean = self._device.last_clean_details() + self.dnd_state = self._device.dnd_status() self._available = True except (OSError, DeviceException) as exc: @@ -494,7 +507,7 @@ class MiroboVacuum(StateVacuumEntity): # Fetch timers separately, see #38285 try: - self._timers = self._vacuum.timer() + self._timers = self._device.timer() except DeviceException as exc: _LOGGER.debug( "Unable to fetch timers, this may happen on some devices: %s", exc @@ -507,6 +520,6 @@ class MiroboVacuum(StateVacuumEntity): _zone.append(repeats) _LOGGER.debug("Zone with repeats: %s", zone) try: - await self.hass.async_add_executor_job(self._vacuum.zoned_clean, zone) + await self.hass.async_add_executor_job(self._device.zoned_clean, zone) except (OSError, DeviceException) as exc: _LOGGER.error("Unable to send zoned_clean command to the vacuum: %s", exc) diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index b56d43b9c9c..ced8bd19e40 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -2,6 +2,6 @@ "domain": "xmpp", "name": "Jabber (XMPP)", "documentation": "https://www.home-assistant.io/integrations/xmpp", - "requirements": ["slixmpp==1.6.0"], + "requirements": ["slixmpp==1.7.0"], "codeowners": ["@fabaff", "@flowolf"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 78dc3c43032..68a041a2887 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -55,7 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, vol.Optional(CONF_ROOM, default=""): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, @@ -87,7 +87,7 @@ class XmppNotificationService(BaseNotificationService): self._sender = sender self._resource = resource self._password = password - self._recipient = recipient + self._recipients = recipient self._tls = tls self._verify = verify self._room = room @@ -102,7 +102,7 @@ class XmppNotificationService(BaseNotificationService): await async_send_message( f"{self._sender}/{self._resource}", self._password, - self._recipient, + self._recipients, self._tls, self._verify, self._room, @@ -116,7 +116,7 @@ class XmppNotificationService(BaseNotificationService): async def async_send_message( sender, password, - recipient, + recipients, use_tls, verify_certificate, room, @@ -182,19 +182,21 @@ async def async_send_message( url = await self.upload_file(timeout=timeout) _LOGGER.info("Upload success") - if room: - _LOGGER.info("Sending file to %s", room) - message = self.Message(sto=room, stype="groupchat") - else: - _LOGGER.info("Sending file to %s", recipient) - message = self.Message(sto=recipient, stype="chat") - - message["body"] = url - message["oob"]["url"] = url - try: - message.send() - except (IqError, IqTimeout, XMPPError) as ex: - _LOGGER.error("Could not send image message %s", ex) + for recipient in recipients: + if room: + _LOGGER.info("Sending file to %s", room) + message = self.Message(sto=room, stype="groupchat") + else: + _LOGGER.info("Sending file to %s", recipient) + message = self.Message(sto=recipient, stype="chat") + message["body"] = url + message["oob"]["url"] = url + try: + message.send() + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Could not send image message %s", ex) + if room: + break except (IqError, IqTimeout, XMPPError) as ex: _LOGGER.error("Upload error, could not send message %s", ex) except NotConnectedError as ex: @@ -336,8 +338,9 @@ async def async_send_message( self.plugin["xep_0045"].join_muc(room, sender, wait=True) self.send_message(mto=room, mbody=message, mtype="groupchat") else: - _LOGGER.debug("Sending message to %s", recipient) - self.send_message(mto=recipient, mbody=message, mtype="chat") + for recipient in recipients: + _LOGGER.debug("Sending message to %s", recipient) + self.send_message(mto=recipient, mbody=message, mtype="chat") except (IqError, IqTimeout, XMPPError) as ex: _LOGGER.error("Could not send text message %s", ex) except NotConnectedError as ex: diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index b61df3f810b..f24847a2d54 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -42,7 +42,6 @@ CONF_FLOW_PARAMS = "flow_params" CONF_CUSTOM_EFFECTS = "custom_effects" CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" -CONF_DEVICE = "device" DATA_CONFIG_ENTRIES = "config_entries" DATA_CUSTOM_EFFECTS = "custom_effects" @@ -423,7 +422,6 @@ class YeelightDevice: Uses brightness as it appears to be supported in both ceiling and other lights. """ - return self._nightlight_brightness is not None @property diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 52f27932403..f186c897a21 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -5,12 +5,11 @@ import voluptuous as vol import yeelight from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from . import ( - CONF_DEVICE, CONF_MODE_MUSIC, CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index c256cfb23e0..e4044303ef0 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -261,7 +261,6 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up Yeelight from a config entry.""" - custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] @@ -563,7 +562,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def device_state_attributes(self): """Return the device specific state attributes.""" - attributes = { "flowing": self.device.is_color_flow_enabled, "music_mode": self._bulb.music_mode, diff --git a/homeassistant/components/yeelight/translations/ko.json b/homeassistant/components/yeelight/translations/ko.json index 7164e56b595..1d6974aaa61 100644 --- a/homeassistant/components/yeelight/translations/ko.json +++ b/homeassistant/components/yeelight/translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "\uc7a5\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.", - "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c \uc0c1\uc5d0 \ubc1c\uacac\ub41c \uc7a5\uce58\uac00 \uc5c6\uc2b5\ub2c8\ub2e4." + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" }, "error": { - "cannot_connect": "\uc5f0\uacb0 \uc2e4\ud328" + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "pick_device": { diff --git a/homeassistant/components/zerproc/translations/nl.json b/homeassistant/components/zerproc/translations/nl.json new file mode 100644 index 00000000000..d11896014fd --- /dev/null +++ b/homeassistant/components/zerproc/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je beginnen met instellen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 91fe51f7793..2bd712ff681 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -469,6 +469,19 @@ async def websocket_reconfigure_node(hass, connection, msg): hass.async_create_task(device.async_configure()) +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +async def websocket_update_topology(hass, connection, msg): + """Update the ZHA network topology.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + @websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( @@ -1143,6 +1156,7 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_get_bindable_devices) websocket_api.async_register_command(hass, websocket_bind_devices) websocket_api.async_register_command(hass, websocket_unbind_devices) + websocket_api.async_register_command(hass, websocket_update_topology) @callback diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 48f35e035f0..a0d8abc1233 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,6 +1,5 @@ """Binary sensors on Zigbee Home Automation networks.""" import functools -import logging from homeassistant.components.binary_sensor import ( DEVICE_CLASS_GAS, @@ -32,8 +31,6 @@ from .core.const import ( from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity -_LOGGER = logging.getLogger(__name__) - # Zigbee Cluster Library Zone Type to Home Assistant device class CLASS_MAPPING = { 0x000D: DEVICE_CLASS_MOTION, diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 473d39c6f7a..f59f53c7995 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -45,6 +45,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + (f" - {p.manufacturer}" if p.manufacturer else "") for p in ports ] + + if not list_of_ports: + return await self.async_step_pick_radio() + list_of_ports.append(CONF_MANUAL_PATH) if user_input is not None: diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 1bd8a52b6e6..852d576c035 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -1,4 +1,6 @@ """Channels module for Zigbee Home Automation.""" +from __future__ import annotations + import asyncio from typing import Any, Dict, List, Optional, Tuple, Union @@ -47,7 +49,7 @@ class Channels: self._zha_device = zha_device @property - def pools(self) -> List["ChannelPool"]: + def pools(self) -> List[ChannelPool]: """Return channel pools list.""" return self._pools @@ -102,7 +104,7 @@ class Channels: } @classmethod - def new(cls, zha_device: zha_typing.ZhaDeviceType) -> "Channels": + def new(cls, zha_device: zha_typing.ZhaDeviceType) -> Channels: """Create new instance.""" channels = cls(zha_device) for ep_id in sorted(zha_device.device.endpoints): @@ -263,7 +265,7 @@ class ChannelPool: ) @classmethod - def new(cls, channels: Channels, ep_id: int) -> "ChannelPool": + def new(cls, channels: Channels, ep_id: int) -> ChannelPool: """Create new channels for an endpoint.""" pool = cls(channels, ep_id) pool.add_all_channels() diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 96f005ba288..db30e9e178c 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -1,6 +1,7 @@ """Entity for Zigbee Home Automation.""" import asyncio +import functools import logging from typing import Any, Awaitable, Dict, List, Optional @@ -165,7 +166,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", - self.async_remove, + functools.partial(self.async_remove, force_remove=True), signal_override=True, ) @@ -239,7 +240,7 @@ class ZhaGroupEntity(BaseZhaEntity): return self._handled_group_membership = True - await self.async_remove() + await self.async_remove(force_remove=True) async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index b25d1c1aa39..ed041f8c6c0 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -1,22 +1,28 @@ """Fans on Zigbee Home Automation networks.""" +from abc import abstractmethod import functools +import math from typing import List, Optional from zigpy.exceptions import ZigbeeException import zigpy.zcl.clusters.hvac as hvac from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_SET_SPEED, FanEntity, + NotValidPresetModeError, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .core import discovery from .core.const import ( @@ -32,24 +38,20 @@ from .entity import ZhaEntity, ZhaGroupEntity # Additional speeds in zigbee's ZCL # Spec is unclear as to what this value means. On King Of Fans HBUniversal # receiver, this means Very High. -SPEED_ON = "on" +PRESET_MODE_ON = "on" # The fan speed is self-regulated -SPEED_AUTO = "auto" +PRESET_MODE_AUTO = "auto" # When the heated/cooled space is occupied, the fan is always on -SPEED_SMART = "smart" +PRESET_MODE_SMART = "smart" -SPEED_LIST = [ - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - SPEED_ON, - SPEED_AUTO, - SPEED_SMART, -] +SPEED_RANGE = (1, 3) # off is not included +PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} + +NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()} +PRESET_MODES = list(NAME_TO_PRESET_MODE) + +DEFAULT_ON_PERCENTAGE = 50 -VALUE_TO_SPEED = dict(enumerate(SPEED_LIST)) -SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN) @@ -74,42 +76,49 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BaseFan(FanEntity): """Base representation of a ZHA fan.""" - def __init__(self, *args, **kwargs): - """Initialize the fan.""" - super().__init__(*args, **kwargs) - self._state = None - self._fan_channel = None - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return SPEED_LIST - - @property - def speed(self) -> str: - """Return the current speed.""" - return self._state + def preset_modes(self) -> List[str]: + """Return the available preset modes.""" + return PRESET_MODES @property def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs) -> None: - """Turn the entity on.""" - if speed is None: - speed = SPEED_MEDIUM + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) - await self.async_set_speed(speed) + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ) -> None: + """Turn the entity on.""" + if percentage is None: + percentage = DEFAULT_ON_PERCENTAGE + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" - await self.async_set_speed(SPEED_OFF) + await self.async_set_percentage(0) - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) - self.async_set_state(0, "fan_mode", speed) + async def async_set_percentage(self, percentage: Optional[int]) -> None: + """Set the speed percenage of the fan.""" + fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + if preset_mode not in self.preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + await self._async_set_fan_mode(NAME_TO_PRESET_MODE[preset_mode]) + + @abstractmethod + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" @callback def async_set_state(self, attr_id, attr_name, value): @@ -133,15 +142,32 @@ class ZhaFan(BaseFan, ZhaEntity): ) @property - def speed(self) -> Optional[str]: - """Return the current speed.""" - return VALUE_TO_SPEED.get(self._fan_channel.fan_mode) + def percentage(self) -> Optional[int]: + """Return the current speed percentage.""" + if ( + self._fan_channel.fan_mode is None + or self._fan_channel.fan_mode > SPEED_RANGE[1] + ): + return None + if self._fan_channel.fan_mode == 0: + return 0 + return ranged_value_to_percentage(SPEED_RANGE, self._fan_channel.fan_mode) + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode.""" + return PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) @callback def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" self.async_write_ha_state() + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_channel.async_set_speed(fan_mode) + self.async_set_state(0, "fan_mode", fan_mode) + @GROUP_MATCH() class FanGroup(BaseFan, ZhaGroupEntity): @@ -155,30 +181,48 @@ class FanGroup(BaseFan, ZhaGroupEntity): self._available: bool = False group = self.zha_device.gateway.get_group(self._group_id) self._fan_channel = group.endpoint[hvac.Fan.cluster_id] + self._percentage = None + self._preset_mode = None - # what should we do with this hack? - async def async_set_speed(value) -> None: - """Set the speed of the fan.""" - try: - await self._fan_channel.write_attributes({"fan_mode": value}) - except ZigbeeException as ex: - self.error("Could not set speed: %s", ex) - return + @property + def percentage(self) -> Optional[int]: + """Return the current speed percentage.""" + return self._percentage - self._fan_channel.async_set_speed = async_set_speed + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode.""" + return self._preset_mode + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the group.""" + try: + await self._fan_channel.write_attributes({"fan_mode": fan_mode}) + except ZigbeeException as ex: + self.error("Could not set fan mode: %s", ex) + self.async_set_state(0, "fan_mode", fan_mode) async def async_update(self): """Attempt to retrieve on off state from the fan.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states: List[State] = list(filter(None, all_states)) - on_states: List[State] = [state for state in states if state.state != SPEED_OFF] - + percentage_states: List[State] = [ + state for state in states if state.attributes.get(ATTR_PERCENTAGE) + ] + preset_mode_states: List[State] = [ + state for state in states if state.attributes.get(ATTR_PRESET_MODE) + ] self._available = any(state.state != STATE_UNAVAILABLE for state in states) - # for now just use first non off state since its kind of arbitrary - if not on_states: - self._state = SPEED_OFF + + if percentage_states: + self._percentage = percentage_states[0].attributes[ATTR_PERCENTAGE] + self._preset_mode = None + elif preset_mode_states: + self._preset_mode = preset_mode_states[0].attributes[ATTR_PRESET_MODE] + self._percentage = None else: - self._state = on_states[0].state + self._percentage = None + self._preset_mode = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a24c20872f2..7d367c3dc00 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.21.0", + "bellows==0.22.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.53", + "zha-quirks==0.0.54", "zigpy-cc==0.5.2", "zigpy-deconz==0.11.1", "zigpy==0.32.0", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.3.0" + "zigpy-znp==0.4.0" ], "codeowners": ["@dmulcahey", "@adminiuga"] } diff --git a/homeassistant/components/zha/translations/ko.json b/homeassistant/components/zha/translations/ko.json index 93582cc9202..639cc84d86f 100644 --- a/homeassistant/components/zha/translations/ko.json +++ b/homeassistant/components/zha/translations/ko.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 ZHA \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { - "cannot_connect": "ZHA \uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" }, "step": { "pick_radio": { diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 01a8b9aa0f4..e1d48cbe1ff 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,4 +1,6 @@ """Support for the definition of zones.""" +from __future__ import annotations + import logging from typing import Any, Dict, Optional, cast @@ -25,7 +27,6 @@ from homeassistant.helpers import ( config_validation as cv, entity, entity_component, - entity_registry, service, storage, ) @@ -183,8 +184,8 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: yaml_collection = collection.IDLessCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) - collection.attach_entity_component_collection( - component, yaml_collection, lambda conf: Zone(conf, False) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, Zone.from_yaml ) storage_collection = ZoneStorageCollection( @@ -192,8 +193,8 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - collection.attach_entity_component_collection( - component, storage_collection, lambda conf: Zone(conf, True) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, Zone ) if config[DOMAIN]: @@ -205,18 +206,6 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: - """Handle a collection change: clean up entity registry on removals.""" - if change_type != collection.CHANGE_REMOVED: - return - - ent_reg = await entity_registry.async_get_registry(hass) - ent_reg.async_remove( - cast(str, ent_reg.async_get_entity_id(DOMAIN, DOMAIN, item_id)) - ) - - storage_collection.async_add_listener(_collection_changed) - async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all zones and load new ones from config.""" conf = await component.async_prepare_reload(skip_reset=True) @@ -235,10 +224,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: if component.get_entity("zone.home"): return True - home_zone = Zone( - _home_conf(hass), - True, - ) + home_zone = Zone(_home_conf(hass)) home_zone.entity_id = ENTITY_ID_HOME await component.async_add_entities([home_zone]) @@ -293,13 +279,21 @@ async def async_unload_entry( class Zone(entity.Entity): """Representation of a Zone.""" - def __init__(self, config: Dict, editable: bool): + def __init__(self, config: Dict): """Initialize the zone.""" self._config = config - self._editable = editable + self.editable = True self._attrs: Optional[Dict] = None self._generate_attrs() + @classmethod + def from_yaml(cls, config: Dict) -> Zone: + """Return entity instance initialized from yaml storage.""" + zone = cls(config) + zone.editable = False + zone._generate_attrs() # pylint:disable=protected-access + return zone + @property def state(self) -> str: """Return the state property really does nothing for a zone.""" @@ -346,5 +340,5 @@ class Zone(entity.Entity): ATTR_LONGITUDE: self._config[CONF_LONGITUDE], ATTR_RADIUS: self._config[CONF_RADIUS], ATTR_PASSIVE: self._config[CONF_PASSIVE], - ATTR_EDITABLE: self._editable, + ATTR_EDITABLE: self.editable, } diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index bc827c2ba0d..a5f89a7515d 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -58,7 +58,7 @@ async def async_attach_trigger( zone_state = hass.states.get(zone_entity_id) from_match = condition.zone(hass, zone_state, from_s) if from_s else False - to_match = condition.zone(hass, zone_state, to_s) + to_match = condition.zone(hass, zone_state, to_s) if to_s else False if ( event == EVENT_ENTER diff --git a/homeassistant/components/zoneminder/translations/et.json b/homeassistant/components/zoneminder/translations/et.json index 087158a450d..c8ccef3a44b 100644 --- a/homeassistant/components/zoneminder/translations/et.json +++ b/homeassistant/components/zoneminder/translations/et.json @@ -23,7 +23,7 @@ "password": "Salas\u00f5na", "path": "ZM aadress", "path_zms": "ZMS-i aadress", - "ssl": "Kasutage ZoneMinderiga \u00fchenduse loomiseks SSL-i", + "ssl": "Kasuta ZoneMinderiga \u00fchenduse loomiseks SSL-i", "username": "Kasutajanimi", "verify_ssl": "Kontrolli SSL sertifikaati" }, diff --git a/homeassistant/components/zoneminder/translations/ko.json b/homeassistant/components/zoneminder/translations/ko.json index 3625d6e402e..e03da9ed8fa 100644 --- a/homeassistant/components/zoneminder/translations/ko.json +++ b/homeassistant/components/zoneminder/translations/ko.json @@ -2,25 +2,29 @@ "config": { "abort": { "auth_fail": "\uc0ac\uc6a9\uc790\uba85\uacfc \uc554\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", - "connection_error": "ZoneMinder \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "connection_error": "ZoneMinder \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "create_entry": { "default": "ZoneMinder \uc11c\ubc84\uac00 \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { "auth_fail": "\uc0ac\uc6a9\uc790\uba85\uacfc \uc554\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", - "connection_error": "ZoneMinder \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "connection_error": "ZoneMinder \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "flow_title": "ZoneMinder", "step": { "user": { "data": { "host": "\ud638\uc2a4\ud2b8 \ubc0f \ud3ec\ud2b8(\uc608: 10.10.0.4:8010)", - "password": "\uc554\ud638", + "password": "\ube44\ubc00\ubc88\ud638", "path": "ZMS \uacbd\ub85c", "path_zms": "ZMS \uacbd\ub85c", - "ssl": "ZoneMinder \uc5f0\uacb0\uc5d0 SSL \uc0ac\uc6a9", - "username": "\uc0ac\uc6a9\uc790\uba85", + "ssl": "SSL \uc778\uc99d\uc11c \uc0ac\uc6a9", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", "verify_ssl": "SSL \uc778\uc99d\uc11c \ud655\uc778" }, "title": "ZoneMinder \uc11c\ubc84\ub97c \ucd94\uac00\ud558\uc138\uc694." diff --git a/homeassistant/components/zoneminder/translations/nl.json b/homeassistant/components/zoneminder/translations/nl.json index ebfd26329dc..8aed5085391 100644 --- a/homeassistant/components/zoneminder/translations/nl.json +++ b/homeassistant/components/zoneminder/translations/nl.json @@ -12,7 +12,8 @@ "error": { "auth_fail": "Gebruikersnaam of wachtwoord is onjuist.", "cannot_connect": "Kon niet verbinden", - "connection_error": "Kan geen verbinding maken met een ZoneMinder-server." + "connection_error": "Kan geen verbinding maken met een ZoneMinder-server.", + "invalid_auth": "Ongeldige authenticatie" }, "flow_title": "ZoneMinder", "step": { @@ -22,7 +23,7 @@ "password": "Wachtwoord", "path": "ZM-pad", "path_zms": "ZMS-pad", - "ssl": "Gebruik SSL voor verbindingen met ZoneMinder", + "ssl": "Gebruik een SSL-certificaat", "username": "Gebruikersnaam", "verify_ssl": "Verifieer SSL-certificaat" }, diff --git a/homeassistant/components/zoneminder/translations/ru.json b/homeassistant/components/zoneminder/translations/ru.json index d599e767f64..bee720ee09a 100644 --- a/homeassistant/components/zoneminder/translations/ru.json +++ b/homeassistant/components/zoneminder/translations/ru.json @@ -4,7 +4,7 @@ "auth_fail": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 ZoneMinder.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "create_entry": { "default": "\u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d \u0441\u0435\u0440\u0432\u0435\u0440 ZoneMinder." @@ -13,7 +13,7 @@ "auth_fail": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 ZoneMinder.", - "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "flow_title": "ZoneMinder", "step": { diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 27f6c0a4801..3acf361dd52 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -36,7 +36,6 @@ from homeassistant.helpers.event import async_track_time_change from homeassistant.util import convert import homeassistant.util.dt as dt_util -from . import config_flow # noqa: F401 pylint: disable=unused-import from . import const, websocket_api as wsapi, workaround from .const import ( CONF_AUTOHEAL, diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index df6f5d8b8f5..7fb0fb8e8be 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -1,28 +1,20 @@ """Support for Z-Wave fans.""" import math -from homeassistant.components.fan import ( - DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_SET_SPEED, - FanEntity, -) +from homeassistant.components.fan import DOMAIN, SUPPORT_SET_SPEED, FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import ZWaveDeviceEntity -SPEED_LIST = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - SUPPORTED_FEATURES = SUPPORT_SET_SPEED -# Value will first be divided to an integer -VALUE_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} - -SPEED_TO_VALUE = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 50, SPEED_HIGH: 99} +SPEED_RANGE = (1, 99) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -51,34 +43,36 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): def update_properties(self): """Handle data changes for node values.""" - value = math.ceil(self.values.primary.data * 3 / 100) - self._state = VALUE_TO_SPEED[value] + self._state = self.values.primary.data - def set_speed(self, speed): - """Set the speed of the fan.""" - self.node.set_dimmer(self.values.primary.value_id, SPEED_TO_VALUE[speed]) - - def turn_on(self, speed=None, **kwargs): - """Turn the device on.""" - if speed is None: + def set_percentage(self, percentage): + """Set the speed percentage of the fan.""" + if percentage is None: # Value 255 tells device to return to previous value - self.node.set_dimmer(self.values.primary.value_id, 255) + zwave_speed = 255 + elif percentage == 0: + zwave_speed = 0 else: - self.set_speed(speed) + zwave_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + self.node.set_dimmer(self.values.primary.value_id, zwave_speed) + + def turn_on(self, speed=None, percentage=None, preset_mode=None, **kwargs): + """Turn the device on.""" + self.set_percentage(percentage) def turn_off(self, **kwargs): """Turn the device off.""" self.node.set_dimmer(self.values.primary.value_id, 0) @property - def speed(self): - """Return the current speed.""" - return self._state + def percentage(self): + """Return the current speed percentage.""" + return ranged_value_to_percentage(SPEED_RANGE, self._state) @property - def speed_list(self): - """Get the list of available speeds.""" - return SPEED_LIST + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) @property def supported_features(self): diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 56dea1639a3..faaea30e0ee 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -95,7 +95,7 @@ class ZWaveBaseEntity(Entity): """Remove this entity and add it back.""" async def _async_remove_and_add(): - await self.async_remove() + await self.async_remove(force_remove=True) self.entity_id = None await self.platform.async_add_entities([self]) @@ -104,7 +104,7 @@ class ZWaveBaseEntity(Entity): async def node_removed(self): """Call when a node is removed from the Z-Wave network.""" - await self.async_remove() + await self.async_remove(force_remove=True) registry = await async_get_registry(self.hass) if self.entity_id not in registry.entities: diff --git a/homeassistant/components/zwave/translations/ca.json b/homeassistant/components/zwave/translations/ca.json index 3c97d8c212f..13805a2d1ed 100644 --- a/homeassistant/components/zwave/translations/ca.json +++ b/homeassistant/components/zwave/translations/ca.json @@ -13,7 +13,7 @@ "network_key": "Clau de xarxa (deixa-ho en blanc per generar-la autom\u00e0ticament)", "usb_path": "Ruta del port USB del dispositiu" }, - "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ per obtenir informaci\u00f3 sobre les variables de configuraci\u00f3", + "description": "Aquesta integraci\u00f3 ja no s'actualitzar\u00e0. Utilitza Z-Wave JS per a instal\u00b7lacions noves.\n\nConsulta https://www.home-assistant.io/docs/z-wave/installation/ per a m\u00e9s informaci\u00f3 sobre les variables de configuraci\u00f3", "title": "Configuraci\u00f3 de Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/en.json b/homeassistant/components/zwave/translations/en.json index d13e5575e61..2fe3e15646a 100644 --- a/homeassistant/components/zwave/translations/en.json +++ b/homeassistant/components/zwave/translations/en.json @@ -13,7 +13,7 @@ "network_key": "Network Key (leave blank to auto-generate)", "usb_path": "USB Device Path" }, - "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables", + "description": "This integration is no longer maintained. For new installations, use Z-Wave JS instead.\n\nSee https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables", "title": "Set up Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/et.json b/homeassistant/components/zwave/translations/et.json index ef36101b3ad..b1fa6127076 100644 --- a/homeassistant/components/zwave/translations/et.json +++ b/homeassistant/components/zwave/translations/et.json @@ -13,7 +13,7 @@ "network_key": "V\u00f5rguv\u00f5ti (j\u00e4ta automaatse genereerimise jaoks t\u00fchjaks)", "usb_path": "USB seadme rada" }, - "description": "Konfiguratsioonimuutujate kohta leiad teavet https://www.home-assistant.io/docs/z-wave/installation/", + "description": "Seda sidumist enam ei hallata. Uueks sidumiseks kasuta Z-Wave JS.\n\nKonfiguratsioonimuutujate kohta leiad teavet https://www.home-assistant.io/docs/z-wave/installation/", "title": "Seadista Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/it.json b/homeassistant/components/zwave/translations/it.json index 0534d54f32f..d3522cf0889 100644 --- a/homeassistant/components/zwave/translations/it.json +++ b/homeassistant/components/zwave/translations/it.json @@ -13,7 +13,7 @@ "network_key": "Chiave di rete (lascia vuoto per generare automaticamente)", "usb_path": "Percorso del dispositivo USB" }, - "description": "Vai su https://www.home-assistant.io/docs/z-wave/installation/ per le informazioni sulle variabili di configurazione", + "description": "Questa integrazione non viene pi\u00f9 mantenuta. Per le nuove installazioni, usa invece Z-Wave JS. \n\nVedere https://www.home-assistant.io/docs/z-wave/installation/ per informazioni sulle variabili di configurazione", "title": "Configura Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/ko.json b/homeassistant/components/zwave/translations/ko.json index 1357fd492c5..84c7b4ee4e3 100644 --- a/homeassistant/components/zwave/translations/ko.json +++ b/homeassistant/components/zwave/translations/ko.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Z-Wave \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { "option_error": "Z-Wave \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. USB \uc2a4\ud2f1\uc758 \uacbd\ub85c\uac00 \uc815\ud655\ud569\ub2c8\uae4c?" @@ -12,7 +13,7 @@ "network_key": "\ub124\ud2b8\uc6cc\ud06c \ud0a4 (\uacf5\ub780\uc73c\ub85c \ube44\uc6cc\ub450\uba74 \uc790\ub3d9 \uc0dd\uc131\ud569\ub2c8\ub2e4)", "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" }, - "description": "\uad6c\uc131 \ubcc0\uc218\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/docs/z-wave/installation/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "description": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \ub354 \uc774\uc0c1 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc0c8\ub85c\uc6b4 \uc124\uce58\uc758 \uacbd\uc6b0 Z-Wave JS \ub97c \uc0ac\uc6a9\ud574\uc8fc\uc138\uc694.\n\n\uad6c\uc131 \ubcc0\uc218\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 https://www.home-assistant.io/docs/z-wave/installation/ \uc744 \ucc38\uc870\ud574\uc8fc\uc138\uc694", "title": "Z-Wave \uc124\uc815" } } diff --git a/homeassistant/components/zwave/translations/no.json b/homeassistant/components/zwave/translations/no.json index ba875354f7f..ab5a405f975 100644 --- a/homeassistant/components/zwave/translations/no.json +++ b/homeassistant/components/zwave/translations/no.json @@ -13,7 +13,7 @@ "network_key": "Nettverksn\u00f8kkel (la v\u00e6re tom for automatisk oppretting)", "usb_path": "USB enhetsbane" }, - "description": "Se [www.home-assistant.io/docs/z-wave/installation/](https://www.home-assistant.io/docs/z-wave/installation/) for informasjon om konfigurasjon variablene", + "description": "Denne integrasjonen opprettholdes ikke lenger. For nye installasjoner, bruk Z-Wave JS i stedet. \n\n Se https://www.home-assistant.io/docs/z-wave/installation/ for informasjon om konfigurasjonsvariablene", "title": "Sett opp Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/pl.json b/homeassistant/components/zwave/translations/pl.json index 90ff1a37894..0a4b6a4828c 100644 --- a/homeassistant/components/zwave/translations/pl.json +++ b/homeassistant/components/zwave/translations/pl.json @@ -13,7 +13,7 @@ "network_key": "Klucz sieciowy (pozostaw pusty, by generowa\u0107 automatycznie)", "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" }, - "description": "Przejd\u017a na https://www.home-assistant.io/docs/z-wave/installation/, aby uzyska\u0107 informacje na temat zmiennych konfiguracyjnych", + "description": "Ta integracja nie jest ju\u017c wspierana. Dla nowych instalacji, u\u017cyj Z-Wave JS.\n\nPrzejd\u017a na https://www.home-assistant.io/docs/z-wave/installation/, aby uzyska\u0107 informacje na temat zmiennych konfiguracyjnych", "title": "Konfiguracja Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/ru.json b/homeassistant/components/zwave/translations/ru.json index 515a47d87a6..5188bb8330e 100644 --- a/homeassistant/components/zwave/translations/ru.json +++ b/homeassistant/components/zwave/translations/ru.json @@ -13,7 +13,7 @@ "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/docs/z-wave/installation/) \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 \u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", + "description": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f. \u0420\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0432\u043c\u0435\u0441\u0442\u043e \u043d\u0435\u0451 Z-Wave JS.\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/docs/z-wave/installation/) \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 \u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", "title": "Z-Wave" } } diff --git a/homeassistant/components/zwave/translations/zh-Hant.json b/homeassistant/components/zwave/translations/zh-Hant.json index f5c07a9efc9..545da7b2ee7 100644 --- a/homeassistant/components/zwave/translations/zh-Hant.json +++ b/homeassistant/components/zwave/translations/zh-Hant.json @@ -13,7 +13,7 @@ "network_key": "\u7db2\u8def\u5bc6\u9470\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u6703\u81ea\u52d5\u7522\u751f\uff09", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, - "description": "\u95dc\u65bc\u8a2d\u5b9a\u8b8a\u6578\u8cc7\u8a0a\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/docs/z-wave/installation/", + "description": "\u6b64\u6574\u5408\u5df2\u7d93\u4e0d\u518d\u9032\u884c\u7dad\u8b77\uff0c\u8acb\u4f7f\u7528 Z-Wave JS \u53d6\u4ee3\u70ba\u65b0\u5b89\u88dd\u65b9\u5f0f\u3002\n\n\u8acb\u53c3\u95b1 https://www.home-assistant.io/docs/z-wave/installation/ \u4ee5\n\u7372\u5f97\u8a2d\u5b9a\u8b8a\u6578\u8cc7\u8a0a", "title": "\u8a2d\u5b9a Z-Wave" } } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 01b8f4785c5..d4e349645cf 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -1,54 +1,61 @@ """The Z-Wave JS integration.""" import asyncio -import logging from typing import Callable, List from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.notification import Notification from zwave_js_server.model.value import ValueNotification -from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ATTR_DOMAIN, CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from .addon import AddonError, AddonManager, get_addon_manager from .api import async_register_api from .const import ( ATTR_COMMAND_CLASS, ATTR_COMMAND_CLASS_NAME, ATTR_DEVICE_ID, - ATTR_DOMAIN, ATTR_ENDPOINT, ATTR_HOME_ID, ATTR_LABEL, ATTR_NODE_ID, ATTR_PARAMETERS, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, ATTR_PROPERTY_KEY_NAME, ATTR_PROPERTY_NAME, ATTR_TYPE, ATTR_VALUE, + ATTR_VALUE_RAW, CONF_INTEGRATION_CREATED_ADDON, + CONF_NETWORK_KEY, + CONF_USB_PATH, + CONF_USE_ADDON, DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, PLATFORMS, ZWAVE_JS_EVENT, ) from .discovery import async_discover_values -from .entity import get_device_id +from .helpers import get_device_id, get_old_value_id, get_unique_id +from .services import ZWaveServices -LOGGER = logging.getLogger(__package__) CONNECT_TIMEOUT = 10 DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_START_PLATFORM_TASK = "start_platform_task" +DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" +DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" async def async_setup(hass: HomeAssistant, config: dict) -> bool: @@ -66,22 +73,55 @@ def register_node_in_dev_reg( node: ZwaveNode, ) -> None: """Register node in dev reg.""" - device = dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={get_device_id(client, node)}, - sw_version=node.firmware_version, - name=node.name or node.device_config.description or f"Node {node.node_id}", - model=node.device_config.label, - manufacturer=node.device_config.manufacturer, - ) + params = { + "config_entry_id": entry.entry_id, + "identifiers": {get_device_id(client, node)}, + "sw_version": node.firmware_version, + "name": node.name or node.device_config.description or f"Node {node.node_id}", + "model": node.device_config.label, + "manufacturer": node.device_config.manufacturer, + } + if node.location: + params["suggested_area"] = node.location + device = dev_reg.async_get_or_create(**params) async_dispatcher_send(hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Z-Wave JS from a config entry.""" + use_addon = entry.data.get(CONF_USE_ADDON) + if use_addon: + await async_ensure_addon_running(hass, entry) + client = ZwaveClient(entry.data[CONF_URL], async_get_clientsession(hass)) dev_reg = await device_registry.async_get_registry(hass) + ent_reg = entity_registry.async_get(hass) + + @callback + def migrate_entity(platform: str, old_unique_id: str, new_unique_id: str) -> None: + """Check if entity with old unique ID exists, and if so migrate it to new ID.""" + if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id): + LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + try: + ent_reg.async_update_entity( + entity_id, + new_unique_id=new_unique_id, + ) + except ValueError: + LOGGER.debug( + ( + "Entity %s can't be migrated because the unique ID is taken. " + "Cleaning it up since it is likely no longer valid." + ), + entity_id, + ) + ent_reg.async_remove(entity_id) @callback def async_on_node_ready(node: ZwaveNode) -> None: @@ -94,6 +134,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # run discovery on all node values and create/update entities for disc_info in async_discover_values(node): LOGGER.debug("Discovered entity: %s", disc_info) + + # This migration logic was added in 2021.3 to handle a breaking change to + # the value_id format. Some time in the future, this code block + # (as well as get_old_value_id helper and migrate_entity closure) can be + # removed. + value_ids = [ + # 2021.2.* format + get_old_value_id(disc_info.primary_value), + # 2021.3.0b0 format + disc_info.primary_value.value_id, + ] + + new_unique_id = get_unique_id( + client.driver.controller.home_id, + disc_info.primary_value.value_id, + ) + + for value_id in value_ids: + old_unique_id = get_unique_id( + client.driver.controller.home_id, + f"{disc_info.primary_value.node.node_id}.{value_id}", + ) + # Most entities have the same ID format, but notification binary sensors + # have a state key in their ID so we need to handle them differently + if ( + disc_info.platform == "binary_sensor" + and disc_info.platform_hint == "notification" + ): + for state_key in disc_info.primary_value.metadata.states: + # ignore idle key (0) + if state_key == "0": + continue + + migrate_entity( + disc_info.platform, + f"{old_unique_id}.{state_key}", + f"{new_unique_id}.{state_key}", + ) + + # Once we've iterated through all state keys, we can move on to the + # next item + continue + + migrate_entity(disc_info.platform, old_unique_id, new_unique_id) + async_dispatcher_send( hass, f"{DOMAIN}_{entry.entry_id}_add_{disc_info.platform}", disc_info ) @@ -131,14 +216,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # grab device in device registry attached to this node dev_id = get_device_id(client, node) device = dev_reg.async_get_device({dev_id}) - # note: removal of entity registry is handled by core - dev_reg.async_remove_device(device.id) + # note: removal of entity registry entry is handled by core + dev_reg.async_remove_device(device.id) # type: ignore @callback def async_on_value_notification(notification: ValueNotification) -> None: """Relay stateless value notification events from Z-Wave nodes to hass.""" device = dev_reg.async_get_device({get_device_id(client, notification.node)}) - value = notification.value + raw_value = value = notification.value if notification.metadata.states: value = notification.metadata.states.get(str(value), value) hass.bus.async_fire( @@ -149,13 +234,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, ATTR_ENDPOINT: notification.endpoint, - ATTR_DEVICE_ID: device.id, + ATTR_DEVICE_ID: device.id, # type: ignore ATTR_COMMAND_CLASS: notification.command_class, ATTR_COMMAND_CLASS_NAME: notification.command_class_name, ATTR_LABEL: notification.metadata.label, + ATTR_PROPERTY: notification.property_, ATTR_PROPERTY_NAME: notification.property_name, + ATTR_PROPERTY_KEY: notification.property_key, ATTR_PROPERTY_KEY_NAME: notification.property_key_name, ATTR_VALUE: value, + ATTR_VALUE_RAW: raw_value, }, ) @@ -170,26 +258,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, - ATTR_DEVICE_ID: device.id, + ATTR_DEVICE_ID: device.id, # type: ignore ATTR_LABEL: notification.notification_label, ATTR_PARAMETERS: notification.parameters, }, ) + entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) # connect and throw error if connection failed try: async with timeout(CONNECT_TIMEOUT): await client.connect() + except InvalidServerVersion as err: + if not entry_hass_data.get(DATA_INVALID_SERVER_VERSION_LOGGED): + LOGGER.error("Invalid server version: %s", err) + entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = True + if use_addon: + async_ensure_addon_updated(hass) + raise ConfigEntryNotReady from err except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: + if not entry_hass_data.get(DATA_CONNECT_FAILED_LOGGED): + LOGGER.error("Failed to connect: %s", err) + entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = True raise ConfigEntryNotReady from err else: LOGGER.info("Connected to Zwave JS Server") + entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = False + entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = False unsubscribe_callbacks: List[Callable] = [] - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_UNSUBSCRIBE: unsubscribe_callbacks, - } + entry_hass_data[DATA_CLIENT] = client + entry_hass_data[DATA_UNSUBSCRIBE] = unsubscribe_callbacks + + services = ZWaveServices(hass, ent_reg) + services.async_register() # Set up websocket API async_register_api(hass) @@ -213,12 +315,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listen_task = asyncio.create_task( client_listen(hass, entry, client, driver_ready) ) - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT_LISTEN_TASK] = listen_task + entry_hass_data[DATA_CLIENT_LISTEN_TASK] = listen_task unsubscribe_callbacks.append( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) - await driver_ready.wait() + try: + await driver_ready.wait() + except asyncio.CancelledError: + LOGGER.debug("Cancelling start platforms") + return LOGGER.info("Connection to Zwave JS Server initialized") @@ -251,7 +357,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) platform_task = hass.async_create_task(start_platforms()) - hass.data[DOMAIN][entry.entry_id][DATA_START_PLATFORM_TASK] = platform_task + entry_hass_data[DATA_START_PLATFORM_TASK] = platform_task return True @@ -268,8 +374,11 @@ async def client_listen( await client.listen(driver_ready) except asyncio.CancelledError: should_reload = False - except BaseZwaveJSServerError: - pass + except BaseZwaveJSServerError as err: + LOGGER.error("Failed to listen: %s", err) + except Exception as err: # pylint: disable=broad-except + # We need to guard against unknown exceptions to not crash this task. + LOGGER.exception("Unexpected exception: %s", err) # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. @@ -324,6 +433,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: platform_task=info[DATA_START_PLATFORM_TASK], ) + if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: + addon_manager: AddonManager = get_addon_manager(hass) + LOGGER.debug("Stopping Z-Wave JS add-on") + try: + await addon_manager.async_stop_addon() + except AddonError as err: + LOGGER.error("Failed to stop the Z-Wave JS add-on: %s", err) + return False + return True @@ -332,12 +450,51 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): return + addon_manager: AddonManager = get_addon_manager(hass) try: - await hass.components.hassio.async_stop_addon("core_zwave_js") - except HassioAPIError as err: - LOGGER.error("Failed to stop the Z-Wave JS add-on: %s", err) + await addon_manager.async_stop_addon() + except AddonError as err: + LOGGER.error(err) return try: - await hass.components.hassio.async_uninstall_addon("core_zwave_js") - except HassioAPIError as err: - LOGGER.error("Failed to uninstall the Z-Wave JS add-on: %s", err) + await addon_manager.async_create_snapshot() + except AddonError as err: + LOGGER.error(err) + return + try: + await addon_manager.async_uninstall_addon() + except AddonError as err: + LOGGER.error(err) + + +async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Ensure that Z-Wave JS add-on is installed and running.""" + addon_manager: AddonManager = get_addon_manager(hass) + if addon_manager.task_in_progress(): + raise ConfigEntryNotReady + try: + addon_is_installed = await addon_manager.async_is_addon_installed() + addon_is_running = await addon_manager.async_is_addon_running() + except AddonError as err: + LOGGER.error("Failed to get the Z-Wave JS add-on info") + raise ConfigEntryNotReady from err + + usb_path: str = entry.data[CONF_USB_PATH] + network_key: str = entry.data[CONF_NETWORK_KEY] + + if not addon_is_installed: + addon_manager.async_schedule_install_addon(usb_path, network_key) + raise ConfigEntryNotReady + + if not addon_is_running: + addon_manager.async_schedule_setup_addon(usb_path, network_key) + raise ConfigEntryNotReady + + +@callback +def async_ensure_addon_updated(hass: HomeAssistant) -> None: + """Ensure that Z-Wave JS add-on is updated and running.""" + addon_manager: AddonManager = get_addon_manager(hass) + if addon_manager.task_in_progress(): + raise ConfigEntryNotReady + addon_manager.async_schedule_update_addon() diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py new file mode 100644 index 00000000000..54169dcaf94 --- /dev/null +++ b/homeassistant/components/zwave_js/addon.py @@ -0,0 +1,246 @@ +"""Provide add-on management.""" +from __future__ import annotations + +import asyncio +from functools import partial +from typing import Any, Callable, Optional, TypeVar, cast + +from homeassistant.components.hassio import ( + async_create_snapshot, + async_get_addon_discovery_info, + async_get_addon_info, + async_install_addon, + async_set_addon_options, + async_start_addon, + async_stop_addon, + async_uninstall_addon, + async_update_addon, +) +from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton + +from .const import ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, DOMAIN, LOGGER + +F = TypeVar("F", bound=Callable[..., Any]) # pylint: disable=invalid-name + +DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" + + +@singleton(DATA_ADDON_MANAGER) +@callback +def get_addon_manager(hass: HomeAssistant) -> AddonManager: + """Get the add-on manager.""" + return AddonManager(hass) + + +def api_error(error_message: str) -> Callable[[F], F]: + """Handle HassioAPIError and raise a specific AddonError.""" + + def handle_hassio_api_error(func: F) -> F: + """Handle a HassioAPIError.""" + + async def wrapper(*args, **kwargs): # type: ignore + """Wrap an add-on manager method.""" + try: + return_value = await func(*args, **kwargs) + except HassioAPIError as err: + raise AddonError(error_message) from err + + return return_value + + return cast(F, wrapper) + + return handle_hassio_api_error + + +class AddonManager: + """Manage the add-on. + + Methods may raise AddonError. + Only one instance of this class may exist + to keep track of running add-on tasks. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Set up the add-on manager.""" + self._hass = hass + self._install_task: Optional[asyncio.Task] = None + self._update_task: Optional[asyncio.Task] = None + self._setup_task: Optional[asyncio.Task] = None + + def task_in_progress(self) -> bool: + """Return True if any of the add-on tasks are in progress.""" + return any( + task and not task.done() + for task in ( + self._install_task, + self._setup_task, + self._update_task, + ) + ) + + @api_error("Failed to get Z-Wave JS add-on discovery info") + async def async_get_addon_discovery_info(self) -> dict: + """Return add-on discovery info.""" + discovery_info = await async_get_addon_discovery_info(self._hass, ADDON_SLUG) + + if not discovery_info: + raise AddonError("Failed to get Z-Wave JS add-on discovery info") + + discovery_info_config: dict = discovery_info["config"] + return discovery_info_config + + @api_error("Failed to get the Z-Wave JS add-on info") + async def async_get_addon_info(self) -> dict: + """Return and cache Z-Wave JS add-on info.""" + addon_info: dict = await async_get_addon_info(self._hass, ADDON_SLUG) + return addon_info + + async def async_is_addon_running(self) -> bool: + """Return True if Z-Wave JS add-on is running.""" + addon_info = await self.async_get_addon_info() + return bool(addon_info["state"] == "started") + + async def async_is_addon_installed(self) -> bool: + """Return True if Z-Wave JS add-on is installed.""" + addon_info = await self.async_get_addon_info() + return addon_info["version"] is not None + + async def async_get_addon_options(self) -> dict: + """Get Z-Wave JS add-on options.""" + addon_info = await self.async_get_addon_info() + return cast(dict, addon_info["options"]) + + @api_error("Failed to set the Z-Wave JS add-on options") + async def async_set_addon_options(self, config: dict) -> None: + """Set Z-Wave JS add-on options.""" + options = {"options": config} + await async_set_addon_options(self._hass, ADDON_SLUG, options) + + @api_error("Failed to install the Z-Wave JS add-on") + async def async_install_addon(self) -> None: + """Install the Z-Wave JS add-on.""" + await async_install_addon(self._hass, ADDON_SLUG) + + @callback + def async_schedule_install_addon( + self, usb_path: str, network_key: str + ) -> asyncio.Task: + """Schedule a task that installs and sets up the Z-Wave JS add-on. + + Only schedule a new install task if the there's no running task. + """ + if not self._install_task or self._install_task.done(): + LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + self._install_task = self._async_schedule_addon_operation( + self.async_install_addon, + partial(self.async_setup_addon, usb_path, network_key), + ) + return self._install_task + + @api_error("Failed to uninstall the Z-Wave JS add-on") + async def async_uninstall_addon(self) -> None: + """Uninstall the Z-Wave JS add-on.""" + await async_uninstall_addon(self._hass, ADDON_SLUG) + + @api_error("Failed to update the Z-Wave JS add-on") + async def async_update_addon(self) -> None: + """Update the Z-Wave JS add-on if needed.""" + addon_info = await self.async_get_addon_info() + addon_version = addon_info["version"] + update_available = addon_info["update_available"] + + if addon_version is None: + raise AddonError("Z-Wave JS add-on is not installed") + + if not update_available: + return + + await async_update_addon(self._hass, ADDON_SLUG) + + @callback + def async_schedule_update_addon(self) -> asyncio.Task: + """Schedule a task that updates and sets up the Z-Wave JS add-on. + + Only schedule a new update task if the there's no running task. + """ + if not self._update_task or self._update_task.done(): + LOGGER.info("Trying to update the Z-Wave JS add-on") + self._update_task = self._async_schedule_addon_operation( + self.async_create_snapshot, self.async_update_addon + ) + return self._update_task + + @api_error("Failed to start the Z-Wave JS add-on") + async def async_start_addon(self) -> None: + """Start the Z-Wave JS add-on.""" + await async_start_addon(self._hass, ADDON_SLUG) + + @api_error("Failed to stop the Z-Wave JS add-on") + async def async_stop_addon(self) -> None: + """Stop the Z-Wave JS add-on.""" + await async_stop_addon(self._hass, ADDON_SLUG) + + async def async_setup_addon(self, usb_path: str, network_key: str) -> None: + """Configure and start Z-Wave JS add-on.""" + addon_options = await self.async_get_addon_options() + + new_addon_options = { + CONF_ADDON_DEVICE: usb_path, + CONF_ADDON_NETWORK_KEY: network_key, + } + + if new_addon_options != addon_options: + await self.async_set_addon_options(new_addon_options) + + await self.async_start_addon() + + @callback + def async_schedule_setup_addon( + self, usb_path: str, network_key: str + ) -> asyncio.Task: + """Schedule a task that configures and starts the Z-Wave JS add-on. + + Only schedule a new setup task if the there's no running task. + """ + if not self._setup_task or self._setup_task.done(): + LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + self._setup_task = self._async_schedule_addon_operation( + partial(self.async_setup_addon, usb_path, network_key) + ) + return self._setup_task + + @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") + async def async_create_snapshot(self) -> None: + """Create a partial snapshot of the Z-Wave JS add-on.""" + addon_info = await self.async_get_addon_info() + addon_version = addon_info["version"] + name = f"addon_{ADDON_SLUG}_{addon_version}" + + LOGGER.debug("Creating snapshot: %s", name) + await async_create_snapshot( + self._hass, + {"name": name, "addons": [ADDON_SLUG]}, + partial=True, + ) + + @callback + def _async_schedule_addon_operation(self, *funcs: Callable) -> asyncio.Task: + """Schedule an add-on task.""" + + async def addon_operation() -> None: + """Do the add-on operation and catch AddonError.""" + for func in funcs: + try: + await func() + except AddonError as err: + LOGGER.error(err) + break + + return self._hass.async_create_task(addon_operation()) + + +class AddonError(HomeAssistantError): + """Represent an error with Z-Wave JS add-on.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 03a917217a9..a48eadfad1d 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,28 +1,49 @@ """Websocket API for Z-Wave JS.""" +import dataclasses import json -import logging +from typing import Dict from aiohttp import hdrs, web, web_exceptions import voluptuous as vol from zwave_js_server import dump +from zwave_js_server.const import LogLevel +from zwave_js_server.exceptions import InvalidNewValue, NotFoundError, SetValueFailed +from zwave_js_server.model.log_config import LogConfig +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_NOT_FOUND, + ERR_NOT_SUPPORTED, + ERR_UNKNOWN_ERROR, +) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY -_LOGGER = logging.getLogger(__name__) - +# general API constants ID = "id" ENTRY_ID = "entry_id" NODE_ID = "node_id" TYPE = "type" +PROPERTY = "property" +PROPERTY_KEY = "property_key" +VALUE = "value" + +# constants for log config commands +CONFIG = "config" +LEVEL = "level" +LOG_TO_FILE = "log_to_file" +FILENAME = "filename" +ENABLED = "enabled" +FORCE_CONSOLE = "force_console" @callback @@ -34,6 +55,10 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_remove_node) websocket_api.async_register_command(hass, websocket_stop_exclusion) + websocket_api.async_register_command(hass, websocket_update_log_config) + websocket_api.async_register_command(hass, websocket_get_log_config) + websocket_api.async_register_command(hass, websocket_get_config_parameters) + websocket_api.async_register_command(hass, websocket_set_config_parameter) hass.http.register_view(DumpView) # type: ignore @@ -266,6 +291,171 @@ async def websocket_remove_node( ) +@websocket_api.require_admin # type:ignore +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/set_config_parameter", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + vol.Required(PROPERTY): int, + vol.Optional(PROPERTY_KEY): int, + vol.Required(VALUE): int, + } +) +async def websocket_set_config_parameter( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Set a config parameter value for a Z-Wave node.""" + entry_id = msg[ENTRY_ID] + node_id = msg[NODE_ID] + property_ = msg[PROPERTY] + property_key = msg.get(PROPERTY_KEY) + value = msg[VALUE] + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + node = client.driver.controller.nodes[node_id] + try: + result = await async_set_config_parameter( + node, value, property_, property_key=property_key + ) + except (InvalidNewValue, NotFoundError, NotImplementedError, SetValueFailed) as err: + code = ERR_UNKNOWN_ERROR + if isinstance(err, NotFoundError): + code = ERR_NOT_FOUND + elif isinstance(err, (InvalidNewValue, NotImplementedError)): + code = ERR_NOT_SUPPORTED + + connection.send_error( + msg[ID], + code, + str(err), + ) + return + + connection.send_result( + msg[ID], + str(result), + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_config_parameters", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@callback +def websocket_get_config_parameters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Get a list of configuration parameterss for a Z-Wave node.""" + entry_id = msg[ENTRY_ID] + node_id = msg[NODE_ID] + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + node = client.driver.controller.nodes[node_id] + values = node.get_configuration_values() + result = {} + for value_id, zwave_value in values.items(): + metadata = zwave_value.metadata + result[value_id] = { + "property": zwave_value.property_, + "configuration_value_type": zwave_value.configuration_value_type.value, + "metadata": { + "description": metadata.description, + "label": metadata.label, + "type": metadata.type, + "min": metadata.min, + "max": metadata.max, + "unit": metadata.unit, + "writeable": metadata.writeable, + "readable": metadata.readable, + }, + "value": zwave_value.value, + } + if zwave_value.metadata.states: + result[value_id]["metadata"]["states"] = zwave_value.metadata.states + + connection.send_result( + msg[ID], + result, + ) + + +def convert_log_level_to_enum(value: str) -> LogLevel: + """Convert log level string to LogLevel enum.""" + return LogLevel[value.upper()] + + +def filename_is_present_if_logging_to_file(obj: Dict) -> Dict: + """Validate that filename is provided if log_to_file is True.""" + if obj.get(LOG_TO_FILE, False) and FILENAME not in obj: + raise vol.Invalid("`filename` must be provided if logging to file") + return obj + + +@websocket_api.require_admin # type: ignore +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/update_log_config", + vol.Required(ENTRY_ID): str, + vol.Required(CONFIG): vol.All( + vol.Schema( + { + vol.Optional(ENABLED): cv.boolean, + vol.Optional(LEVEL): vol.All( + cv.string, + vol.Lower, + vol.In([log_level.name.lower() for log_level in LogLevel]), + lambda val: LogLevel[val.upper()], + ), + vol.Optional(LOG_TO_FILE): cv.boolean, + vol.Optional(FILENAME): cv.string, + vol.Optional(FORCE_CONSOLE): cv.boolean, + } + ), + cv.has_at_least_one_key( + ENABLED, FILENAME, FORCE_CONSOLE, LEVEL, LOG_TO_FILE + ), + filename_is_present_if_logging_to_file, + ), + }, +) +async def websocket_update_log_config( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Update the driver log config.""" + entry_id = msg[ENTRY_ID] + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + await client.driver.async_update_log_config(LogConfig(**msg[CONFIG])) + connection.send_result( + msg[ID], + ) + + +@websocket_api.require_admin # type: ignore +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_log_config", + vol.Required(ENTRY_ID): str, + }, +) +async def websocket_get_log_config( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Cancel removing a node from the Z-Wave network.""" + entry_id = msg[ENTRY_ID] + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + result = await client.driver.async_get_log_config() + connection.send_result( + msg[ID], + dataclasses.asdict(result), + ) + + class DumpView(HomeAssistantView): """View to dump the state of the Z-Wave JS server.""" @@ -284,9 +474,9 @@ class DumpView(HomeAssistantView): msgs = await dump.dump_msgs(entry.data[CONF_URL], async_get_clientsession(hass)) return web.Response( - body="\n".join(json.dumps(msg) for msg in msgs) + "\n", + body=json.dumps(msgs, indent=2) + "\n", headers={ - hdrs.CONTENT_TYPE: "application/jsonl", - hdrs.CONTENT_DISPOSITION: 'attachment; filename="zwave_js_dump.jsonl"', + hdrs.CONTENT_TYPE: "application/json", + hdrs.CONTENT_DISPOSITION: 'attachment; filename="zwave_js_dump.json"', }, ) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index ae5444b7079..8d266c83f22 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -257,6 +257,16 @@ async def async_setup_entry( class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor.""" + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveBooleanBinarySensor entity.""" + super().__init__(config_entry, client, info) + self._name = self.generate_name(include_value_name=True) + @property def is_on(self) -> Optional[bool]: """Return if the sensor is on or off.""" @@ -277,7 +287,7 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): if self.info.primary_value.command_class == CommandClass.SENSOR_BINARY: # Legacy binary sensors are phased out (replaced by notification sensors) # Disable by default to not confuse users - if self.info.node.device_class.generic != "Binary Sensor": + if self.info.node.device_class.generic.key != 0x20: return False return True @@ -296,8 +306,9 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): super().__init__(config_entry, client, info) self.state_key = state_key self._name = self.generate_name( - self.info.primary_value.property_name, - [self.info.primary_value.metadata.states[self.state_key]], + include_value_name=True, + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.metadata.states[self.state_key]], ) # check if we have a custom mapping for this value self._mapping_info = self._get_sensor_mapping() @@ -351,6 +362,7 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): super().__init__(config_entry, client, info) # check if we have a custom mapping for this value self._mapping_info = self._get_sensor_mapping() + self._name = self.generate_name(include_value_name=True) @property def is_on(self) -> Optional[bool]: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index a0b0648932c..54966538aae 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -1,6 +1,5 @@ """Representation of Z-Wave thermostats.""" -import logging -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -34,6 +33,7 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_NONE, + SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, @@ -47,8 +47,6 @@ from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity -_LOGGER = logging.getLogger(__name__) - # Map Z-Wave HVAC Mode to Home Assistant value # Note: We treat "auto" as "heat_cool" as most Z-Wave devices # report auto_changeover as auto without schedule support. @@ -84,6 +82,8 @@ HVAC_CURRENT_MAP: Dict[int, str] = { ThermostatOperatingState.THIRD_STAGE_AUX_HEAT: CURRENT_HVAC_HEAT, } +ATTR_FAN_STATE = "fan_state" + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable @@ -125,10 +125,18 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): ) self._setpoint_values: Dict[ThermostatSetpointType, ZwaveValue] = {} for enum in ThermostatSetpointType: + # Some devices don't include a property key so we need to check for value + # ID's, both with and without the property key self._setpoint_values[enum] = self.get_zwave_value( THERMOSTAT_SETPOINT_PROPERTY, command_class=CommandClass.THERMOSTAT_SETPOINT, - value_property_key_name=enum.value, + value_property_key=enum.value.key, + value_property_key_name=enum.value.name, + add_to_watched_value_ids=True, + ) or self.get_zwave_value( + THERMOSTAT_SETPOINT_PROPERTY, + command_class=CommandClass.THERMOSTAT_SETPOINT, + value_property_key_name=enum.value.name, add_to_watched_value_ids=True, ) # Use the first found setpoint value to always determine the temperature unit @@ -151,6 +159,16 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): add_to_watched_value_ids=True, check_all_endpoints=True, ) + self._fan_mode = self.get_zwave_value( + THERMOSTAT_MODE_PROPERTY, + CommandClass.THERMOSTAT_FAN_MODE, + add_to_watched_value_ids=True, + ) + self._fan_state = self.get_zwave_value( + THERMOSTAT_OPERATING_STATE_PROPERTY, + CommandClass.THERMOSTAT_FAN_STATE, + add_to_watched_value_ids=True, + ) self._set_modes_and_presets() def _setpoint_value(self, setpoint_type: ThermostatSetpointType) -> ZwaveValue: @@ -243,7 +261,10 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if self._current_mode and self._current_mode.value is None: # guard missing value return None - temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) + try: + temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) + except ValueError: + return None return temp.value if temp else None @property @@ -252,7 +273,10 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if self._current_mode and self._current_mode.value is None: # guard missing value return None - temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) + try: + temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) + except ValueError: + return None return temp.value if temp else None @property @@ -278,6 +302,40 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Return a list of available preset modes.""" return list(self._hvac_presets) + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + if ( + self._fan_mode + and self._fan_mode.value is not None + and str(self._fan_mode.value) in self._fan_mode.metadata.states + ): + return cast(str, self._fan_mode.metadata.states[str(self._fan_mode.value)]) + return None + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + if self._fan_mode and self._fan_mode.metadata.states: + return list(self._fan_mode.metadata.states.values()) + return None + + @property + def device_state_attributes(self) -> Optional[Dict[str, str]]: + """Return the optional state attributes.""" + if ( + self._fan_state + and self._fan_state.value is not None + and str(self._fan_state.value) in self._fan_state.metadata.states + ): + return { + ATTR_FAN_STATE: self._fan_state.metadata.states[ + str(self._fan_state.value) + ] + } + + return None + @property def supported_features(self) -> int: """Return the list of supported features.""" @@ -286,8 +344,28 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): support |= SUPPORT_TARGET_TEMPERATURE if len(self._current_mode_setpoint_enums) > 1: support |= SUPPORT_TARGET_TEMPERATURE_RANGE + if self._fan_mode: + support |= SUPPORT_FAN_MODE return support + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if not self._fan_mode: + return + + try: + new_state = int( + next( + state + for state, label in self._fan_mode.metadata.states.items() + if label == fan_mode + ) + ) + except StopIteration: + raise ValueError(f"Received an invalid fan mode: {fan_mode}") from None + + await self.info.node.async_set_value(self._fan_mode, new_state) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" assert self.hass diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 5faaa02d03d..37923c574b4 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -8,33 +8,38 @@ from async_timeout import timeout import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, exceptions +from homeassistant.components.hassio import is_hassio from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .addon import AddonError, AddonManager, get_addon_manager from .const import ( # pylint:disable=unused-import + CONF_ADDON_DEVICE, + CONF_ADDON_NETWORK_KEY, CONF_INTEGRATION_CREATED_ADDON, + CONF_NETWORK_KEY, + CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -CONF_ADDON_DEVICE = "device" -CONF_ADDON_NETWORK_KEY = "network_key" -CONF_NETWORK_KEY = "network_key" -CONF_USB_PATH = "usb_path" DEFAULT_URL = "ws://localhost:3000" TITLE = "Z-Wave JS" -ADDON_SETUP_TIME = 10 +ADDON_SETUP_TIMEOUT = 5 +ADDON_SETUP_TIMEOUT_ROUNDS = 4 +SERVER_VERSION_TIMEOUT = 10 ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL, default=DEFAULT_URL): str}) -async def validate_input(hass: core.HomeAssistant, user_input: dict) -> VersionInfo: +async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: """Validate if the user input allows us to connect.""" ws_address = user_input[CONF_URL] @@ -47,18 +52,18 @@ async def validate_input(hass: core.HomeAssistant, user_input: dict) -> VersionI raise InvalidInput("cannot_connect") from err -async def async_get_version_info( - hass: core.HomeAssistant, ws_address: str -) -> VersionInfo: +async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo: """Return Z-Wave JS version info.""" - async with timeout(10): - try: + try: + async with timeout(SERVER_VERSION_TIMEOUT): version_info: VersionInfo = await get_server_version( ws_address, async_get_clientsession(hass) ) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - _LOGGER.error("Failed to connect to Z-Wave JS server: %s", err) - raise CannotConnect from err + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + # We don't want to spam the log if the add-on isn't started + # or takes a long time to start. + _LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err) + raise CannotConnect from err return version_info @@ -71,7 +76,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up flow instance.""" - self.addon_config: Optional[dict] = None self.network_key: Optional[str] = None self.usb_path: Optional[str] = None self.use_addon = False @@ -79,13 +83,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False self.install_task: Optional[asyncio.Task] = None + self.start_task: Optional[asyncio.Task] = None async def async_step_user( self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Handle the initial step.""" assert self.hass # typing - if self.hass.components.hassio.is_hassio(): + if is_hassio(self.hass): # type: ignore # no-untyped-call return await self.async_step_on_supervisor() return await self.async_step_manual() @@ -101,7 +106,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} - assert self.hass # typing try: version_info = await validate_input(self.hass, user_input) except InvalidInput as err: @@ -121,14 +125,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="manual", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_hassio( # type: ignore + async def async_step_hassio( # type: ignore # override self, discovery_info: Dict[str, Any] ) -> Dict[str, Any]: """Receive configuration from add-on discovery info. This flow is triggered by the Z-Wave JS add-on. """ - assert self.hass self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" try: version_info = await async_get_version_info(self.hass, self.ws_address) @@ -151,6 +154,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="hassio_confirm") + @callback def _async_create_entry_from_vars(self) -> Dict[str, Any]: """Return a config entry for the flow.""" return self.async_create_entry( @@ -168,6 +172,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Handle logic when on Supervisor host.""" + # Only one entry with Supervisor add-on support is allowed. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data.get(CONF_USE_ADDON): + return await self.async_step_manual() + if user_input is None: return self.async_show_form( step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA @@ -178,29 +187,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.use_addon = True if await self._async_is_addon_running(): - discovery_info = await self._async_get_addon_discovery_info() - self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - - if not self.unique_id: - assert self.hass - try: - version_info = await async_get_version_info( - self.hass, self.ws_address - ) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id( - version_info.home_id, raise_on_progress=False - ) - - self._abort_if_unique_id_configured() addon_config = await self._async_get_addon_config() self.usb_path = addon_config[CONF_ADDON_DEVICE] self.network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, "") - return self._async_create_entry_from_vars() + return await self.async_step_finish_addon_setup() if await self._async_is_addon_installed(): - return await self.async_step_start_addon() + return await self.async_step_configure_addon() return await self.async_step_install_addon() @@ -208,23 +201,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Install Z-Wave JS add-on.""" - assert self.hass if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) return self.async_show_progress( step_id="install_addon", progress_action="install_addon" ) - assert self.hass try: await self.install_task - except self.hass.components.hassio.HassioAPIError as err: + except AddonError as err: _LOGGER.error("Failed to install Z-Wave JS add-on: %s", err) return self.async_show_progress_done(next_step_id="install_failed") self.integration_created_addon = True - return self.async_show_progress_done(next_step_id="start_addon") + return self.async_show_progress_done(next_step_id="configure_addon") async def async_step_install_failed( self, user_input: Optional[Dict[str, Any]] = None @@ -232,14 +223,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Add-on installation failed.""" return self.async_abort(reason="addon_install_failed") - async def async_step_start_addon( + async def async_step_configure_addon( self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: - """Ask for config and start Z-Wave JS add-on.""" - if self.addon_config is None: - self.addon_config = await self._async_get_addon_config() + """Ask for config for Z-Wave JS add-on.""" + addon_config = await self._async_get_addon_config() - errors = {} + errors: Dict[str, str] = {} if user_input is not None: self.network_key = user_input[CONF_NETWORK_KEY] @@ -250,41 +240,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_ADDON_NETWORK_KEY: self.network_key, } - if new_addon_config != self.addon_config: + if new_addon_config != addon_config: await self._async_set_addon_config(new_addon_config) - assert self.hass - try: - await self.hass.components.hassio.async_start_addon("core_zwave_js") - except self.hass.components.hassio.HassioAPIError as err: - _LOGGER.error("Failed to start Z-Wave JS add-on: %s", err) - errors["base"] = "addon_start_failed" - else: - # Sleep some seconds to let the add-on start properly before connecting. - await asyncio.sleep(ADDON_SETUP_TIME) - discovery_info = await self._async_get_addon_discovery_info() - self.ws_address = ( - f"ws://{discovery_info['host']}:{discovery_info['port']}" - ) + return await self.async_step_start_addon() - if not self.unique_id: - try: - version_info = await async_get_version_info( - self.hass, self.ws_address - ) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id( - version_info.home_id, raise_on_progress=False - ) - - self._abort_if_unique_id_configured() - return self._async_create_entry_from_vars() - - usb_path = self.addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") - network_key = self.addon_config.get( - CONF_ADDON_NETWORK_KEY, self.network_key or "" - ) + usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") data_schema = vol.Schema( { @@ -294,17 +256,97 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="start_addon", data_schema=data_schema, errors=errors + step_id="configure_addon", data_schema=data_schema, errors=errors ) + async def async_step_start_addon( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Start Z-Wave JS add-on.""" + assert self.hass + if not self.start_task: + self.start_task = self.hass.async_create_task(self._async_start_addon()) + return self.async_show_progress( + step_id="start_addon", progress_action="start_addon" + ) + + try: + await self.start_task + except (CannotConnect, AddonError) as err: + _LOGGER.error("Failed to start Z-Wave JS add-on: %s", err) + return self.async_show_progress_done(next_step_id="start_failed") + + return self.async_show_progress_done(next_step_id="finish_addon_setup") + + async def async_step_start_failed( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Add-on start failed.""" + return self.async_abort(reason="addon_start_failed") + + async def _async_start_addon(self) -> None: + """Start the Z-Wave JS add-on.""" + assert self.hass + addon_manager: AddonManager = get_addon_manager(self.hass) + try: + await addon_manager.async_start_addon() + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not self.ws_address: + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = ( + f"ws://{discovery_info['host']}:{discovery_info['port']}" + ) + await async_get_version_info(self.hass, self.ws_address) + except (AbortFlow, CannotConnect) as err: + _LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) + else: + break + else: + raise CannotConnect("Failed to start add-on: timeout") + finally: + # Continue the flow after show progress when the task is done. + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def async_step_finish_addon_setup( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Prepare info needed to complete the config entry. + + Get add-on discovery info and server version info. + Set unique id and abort if already configured. + """ + assert self.hass + if not self.ws_address: + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" + + if not self.unique_id: + try: + version_info = await async_get_version_info(self.hass, self.ws_address) + except CannotConnect as err: + raise AbortFlow("cannot_connect") from err + await self.async_set_unique_id( + version_info.home_id, raise_on_progress=False + ) + + self._abort_if_unique_id_configured() + return self._async_create_entry_from_vars() + async def _async_get_addon_info(self) -> dict: """Return and cache Z-Wave JS add-on info.""" - assert self.hass + addon_manager: AddonManager = get_addon_manager(self.hass) try: - addon_info: dict = await self.hass.components.hassio.async_get_addon_info( - "core_zwave_js" - ) - except self.hass.components.hassio.HassioAPIError as err: + addon_info: dict = await addon_manager.async_get_addon_info() + except AddonError as err: _LOGGER.error("Failed to get Z-Wave JS add-on info: %s", err) raise AbortFlow("addon_info_failed") from err @@ -327,21 +369,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_set_addon_config(self, config: dict) -> None: """Set Z-Wave JS add-on config.""" - assert self.hass options = {"options": config} + addon_manager: AddonManager = get_addon_manager(self.hass) try: - await self.hass.components.hassio.async_set_addon_options( - "core_zwave_js", options - ) - except self.hass.components.hassio.HassioAPIError as err: + await addon_manager.async_set_addon_options(options) + except AddonError as err: _LOGGER.error("Failed to set Z-Wave JS add-on config: %s", err) raise AbortFlow("addon_set_config_failed") from err async def _async_install_addon(self) -> None: """Install the Z-Wave JS add-on.""" - assert self.hass + addon_manager: AddonManager = get_addon_manager(self.hass) try: - await self.hass.components.hassio.async_install_addon("core_zwave_js") + await addon_manager.async_install_addon() finally: # Continue the flow after show progress when the task is done. self.hass.async_create_task( @@ -350,22 +390,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" - assert self.hass + addon_manager: AddonManager = get_addon_manager(self.hass) try: - discovery_info: dict = ( - await self.hass.components.hassio.async_get_addon_discovery_info( - "core_zwave_js" - ) - ) - except self.hass.components.hassio.HassioAPIError as err: + discovery_info_config = await addon_manager.async_get_addon_discovery_info() + except AddonError as err: _LOGGER.error("Failed to get Z-Wave JS add-on discovery info: %s", err) raise AbortFlow("addon_get_discovery_info_failed") from err - if not discovery_info: - _LOGGER.error("Failed to get Z-Wave JS add-on discovery info") - raise AbortFlow("addon_missing_discovery_info") - - discovery_info_config: dict = discovery_info["config"] return discovery_info_config diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index dc2ffaeaa20..ffd6031349a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,5 +1,11 @@ """Constants for the Z-Wave JS integration.""" +import logging + +CONF_ADDON_DEVICE = "device" +CONF_ADDON_NETWORK_KEY = "network_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" +CONF_NETWORK_KEY = "network_key" +CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" DOMAIN = "zwave_js" PLATFORMS = [ @@ -9,6 +15,7 @@ PLATFORMS = [ "fan", "light", "lock", + "number", "sensor", "switch", ] @@ -18,6 +25,8 @@ DATA_UNSUBSCRIBE = "unsubs" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" +LOGGER = logging.getLogger(__package__) + # constants for events ZWAVE_JS_EVENT = f"{DOMAIN}_event" ATTR_NODE_ID = "node_id" @@ -25,11 +34,26 @@ ATTR_HOME_ID = "home_id" ATTR_ENDPOINT = "endpoint" ATTR_LABEL = "label" ATTR_VALUE = "value" +ATTR_VALUE_RAW = "value_raw" ATTR_COMMAND_CLASS = "command_class" ATTR_COMMAND_CLASS_NAME = "command_class_name" ATTR_TYPE = "type" -ATTR_DOMAIN = "domain" ATTR_DEVICE_ID = "device_id" ATTR_PROPERTY_NAME = "property_name" ATTR_PROPERTY_KEY_NAME = "property_key_name" +ATTR_PROPERTY = "property" +ATTR_PROPERTY_KEY = "property_key" ATTR_PARAMETERS = "parameters" + +# service constants +SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" + +ATTR_CONFIG_PARAMETER = "parameter" +ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" +ATTR_CONFIG_VALUE = "value" + +SERVICE_REFRESH_VALUE = "refresh_value" + +ATTR_REFRESH_ALL_VALUES = "refresh_all_values" + +ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 38c891f7376..ff77bdb408d 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -3,9 +3,11 @@ import logging from typing import Any, Callable, List, Optional from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( ATTR_POSITION, + DEVICE_CLASS_GARAGE, DOMAIN as COVER_DOMAIN, SUPPORT_CLOSE, SUPPORT_OPEN, @@ -20,7 +22,15 @@ from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) -SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE + +BARRIER_TARGET_CLOSE = 0 +BARRIER_TARGET_OPEN = 255 + +BARRIER_STATE_CLOSED = 0 +BARRIER_STATE_CLOSING = 252 +BARRIER_STATE_STOPPED = 253 +BARRIER_STATE_OPENING = 254 +BARRIER_STATE_OPEN = 255 async def async_setup_entry( @@ -33,7 +43,10 @@ async def async_setup_entry( def async_add_cover(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave cover.""" entities: List[ZWaveBaseEntity] = [] - entities.append(ZWaveCover(config_entry, client, info)) + if info.platform_hint == "motorized_barrier": + entities.append(ZwaveMotorizedBarrier(config_entry, client, info)) + else: + entities.append(ZWaveCover(config_entry, client, info)) async_add_entities(entities) hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( @@ -99,3 +112,65 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): target_value = self.get_zwave_value("Close") or self.get_zwave_value("Down") if target_value: await self.info.node.async_set_value(target_value, False) + + +class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): + """Representation of a Z-Wave motorized barrier device.""" + + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZwaveMotorizedBarrier entity.""" + super().__init__(config_entry, client, info) + self._target_state: ZwaveValue = self.get_zwave_value( + "targetState", add_to_watched_value_ids=False + ) + + @property + def supported_features(self) -> Optional[int]: + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_GARAGE + + @property + def is_opening(self) -> Optional[bool]: + """Return if the cover is opening or not.""" + if self.info.primary_value.value is None: + return None + return bool(self.info.primary_value.value == BARRIER_STATE_OPENING) + + @property + def is_closing(self) -> Optional[bool]: + """Return if the cover is closing or not.""" + if self.info.primary_value.value is None: + return None + return bool(self.info.primary_value.value == BARRIER_STATE_CLOSING) + + @property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed or not.""" + if self.info.primary_value.value is None: + return None + # If a barrier is in the stopped state, the only way to proceed is by + # issuing an open cover command. Return None in this case which + # produces an unknown state and allows it to be resolved with an open + # command. + if self.info.primary_value.value == BARRIER_STATE_STOPPED: + return None + + return bool(self.info.primary_value.value == BARRIER_STATE_CLOSED) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door.""" + await self.info.node.async_set_value(self._target_state, BARRIER_TARGET_OPEN) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door.""" + await self.info.node.async_set_value(self._target_state, BARRIER_TARGET_CLOSE) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index be6d9b698d4..f5f3d9e5c5b 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1,9 +1,10 @@ """Map Z-Wave nodes and values to Home Assistant entities.""" from dataclasses import dataclass -from typing import Generator, Optional, Set, Union +from typing import Generator, List, Optional, Set, Union from zwave_js_server.const import CommandClass +from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue @@ -14,38 +15,25 @@ from homeassistant.core import callback class ZwaveDiscoveryInfo: """Info discovered from (primary) ZWave Value to create entity.""" - node: ZwaveNode # node to which the value(s) belongs - primary_value: ZwaveValue # the value object itself for primary value - platform: str # the home assistant platform for which an entity should be created - platform_hint: Optional[ - str - ] = "" # hint for the platform about this discovered entity - - @property - def value_id(self) -> str: - """Return the unique value_id belonging to primary value.""" - return f"{self.node.node_id}.{self.primary_value.value_id}" + # node to which the value(s) belongs + node: ZwaveNode + # the value object itself for primary value + primary_value: ZwaveValue + # the home assistant platform for which an entity should be created + platform: str + # hint for the platform about this discovered entity + platform_hint: Optional[str] = "" @dataclass -class ZWaveDiscoverySchema: - """Z-Wave discovery schema. +class ZWaveValueDiscoverySchema: + """Z-Wave Value discovery schema. - The (primary) value for an entity must match these conditions. + The Z-Wave Value must match these conditions. Use the Z-Wave specifications to find out the values for these parameters: https://github.com/zwave-js/node-zwave-js/tree/master/specs """ - # specify the hass platform for which this scheme applies (e.g. light, sensor) - platform: str - # [optional] hint for platform - hint: Optional[str] = None - # [optional] the node's basic device class must match ANY of these values - device_class_basic: Optional[Set[str]] = None - # [optional] the node's generic device class must match ANY of these values - device_class_generic: Optional[Set[str]] = None - # [optional] the node's specific device class must match ANY of these values - device_class_specific: Optional[Set[str]] = None # [optional] the value's command class must match ANY of these values command_class: Optional[Set[int]] = None # [optional] the value's endpoint must match ANY of these values @@ -56,9 +44,110 @@ class ZWaveDiscoverySchema: type: Optional[Set[str]] = None +@dataclass +class ZWaveDiscoverySchema: + """Z-Wave discovery schema. + + The Z-Wave node and it's (primary) value for an entity must match these conditions. + Use the Z-Wave specifications to find out the values for these parameters: + https://github.com/zwave-js/node-zwave-js/tree/master/specs + """ + + # specify the hass platform for which this scheme applies (e.g. light, sensor) + platform: str + # primary value belonging to this discovery scheme + primary_value: ZWaveValueDiscoverySchema + # [optional] hint for platform + hint: Optional[str] = None + # [optional] the node's manufacturer_id must match ANY of these values + manufacturer_id: Optional[Set[int]] = None + # [optional] the node's product_id must match ANY of these values + product_id: Optional[Set[int]] = None + # [optional] the node's product_type must match ANY of these values + product_type: Optional[Set[int]] = None + # [optional] the node's firmware_version must match ANY of these values + firmware_version: Optional[Set[str]] = None + # [optional] the node's basic device class must match ANY of these values + device_class_basic: Optional[Set[Union[str, int]]] = None + # [optional] the node's generic device class must match ANY of these values + device_class_generic: Optional[Set[Union[str, int]]] = None + # [optional] the node's specific device class must match ANY of these values + device_class_specific: Optional[Set[Union[str, int]]] = None + # [optional] additional values that ALL need to be present on the node for this scheme to pass + required_values: Optional[List[ZWaveValueDiscoverySchema]] = None + # [optional] additional values that MAY NOT be present on the node for this scheme to pass + absent_values: Optional[List[ZWaveValueDiscoverySchema]] = None + # [optional] bool to specify if this primary value may be discovered by multiple platforms + allow_multi: bool = False + + +SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={"currentValue"}, + type={"number"}, +) + # For device class mapping see: # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json DISCOVERY_SCHEMAS = [ + # ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS ======= + # Honeywell 39358 In-Wall Fan Control using switch multilevel CC + ZWaveDiscoverySchema( + platform="fan", + manufacturer_id={0x0039}, + product_id={0x3131}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # GE/Jasco fan controllers using switch multilevel CC + ZWaveDiscoverySchema( + platform="fan", + manufacturer_id={0x0063}, + product_id={0x3034, 0x3131, 0x3138}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # Leviton ZW4SF fan controllers using switch multilevel CC + ZWaveDiscoverySchema( + platform="fan", + manufacturer_id={0x001D}, + product_id={0x0002}, + product_type={0x0038}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # Fibaro Shutter Fibaro FGS222 + ZWaveDiscoverySchema( + platform="cover", + manufacturer_id={0x010F}, + product_id={0x1000}, + product_type={0x0302}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # Qubino flush shutter + ZWaveDiscoverySchema( + platform="cover", + manufacturer_id={0x0159}, + product_id={0x0052}, + product_type={0x0003}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # Graber/Bali/Spring Fashion Covers + ZWaveDiscoverySchema( + platform="cover", + manufacturer_id={0x026E}, + product_id={0x5A31}, + product_type={0x4353}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # iBlinds v2 window blind motor + ZWaveDiscoverySchema( + platform="cover", + manufacturer_id={0x0287}, + product_id={0x000D}, + product_type={0x0003}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks ZWaveDiscoverySchema( platform="lock", @@ -69,12 +158,14 @@ DISCOVERY_SCHEMAS = [ "Secure Keypad Door Lock", "Secure Lockbox", }, - command_class={ - CommandClass.LOCK, - CommandClass.DOOR_LOCK, - }, - property={"currentMode", "locked"}, - type={"number", "boolean"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.LOCK, + CommandClass.DOOR_LOCK, + }, + property={"currentMode", "locked"}, + type={"number", "boolean"}, + ), ), # door lock door status ZWaveDiscoverySchema( @@ -87,135 +178,150 @@ DISCOVERY_SCHEMAS = [ "Secure Keypad Door Lock", "Secure Lockbox", }, - command_class={ - CommandClass.LOCK, - CommandClass.DOOR_LOCK, - }, - property={"doorStatus"}, - type={"any"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.LOCK, + CommandClass.DOOR_LOCK, + }, + property={"doorStatus"}, + type={"any"}, + ), ), # climate + # thermostats supporting mode (and optional setpoint) ZWaveDiscoverySchema( platform="climate", - device_class_generic={"Thermostat"}, - device_class_specific={ - "Setback Thermostat", - "Thermostat General", - "Thermostat General V2", - }, - command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, - type={"number"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_MODE}, + property={"mode"}, + type={"number"}, + ), ), - # climate - # setpoint thermostats + # thermostats supporting setpoint only (and thus not mode) ZWaveDiscoverySchema( platform="climate", - device_class_generic={"Thermostat"}, - device_class_specific={ - "Setpoint Thermostat", - }, - command_class={CommandClass.THERMOSTAT_SETPOINT}, - property={"setpoint"}, - type={"number"}, - ), - # lights - # primary value is the currentValue (brightness) - ZWaveDiscoverySchema( - platform="light", - device_class_generic={"Multilevel Switch", "Remote Switch"}, - device_class_specific={ - "Tunable Color Light", - "Binary Tunable Color Light", - "Multilevel Remote Switch", - "Multilevel Power Switch", - "Multilevel Scene Switch", - "Unused", - }, - command_class={CommandClass.SWITCH_MULTILEVEL}, - property={"currentValue"}, - type={"number"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_SETPOINT}, + property={"setpoint"}, + type={"number"}, + ), + absent_values=[ # mode must not be present to prevent dupes + ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_MODE}, + property={"mode"}, + type={"number"}, + ), + ], ), # binary sensors ZWaveDiscoverySchema( platform="binary_sensor", hint="boolean", - command_class={ - CommandClass.SENSOR_BINARY, - CommandClass.BATTERY, - CommandClass.SENSOR_ALARM, - }, - type={"boolean"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.SENSOR_BINARY, + CommandClass.BATTERY, + CommandClass.SENSOR_ALARM, + }, + type={"boolean"}, + ), ), ZWaveDiscoverySchema( platform="binary_sensor", hint="notification", - command_class={ - CommandClass.NOTIFICATION, - }, - type={"number"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={"number"}, + ), + allow_multi=True, ), # generic text sensors ZWaveDiscoverySchema( platform="sensor", hint="string_sensor", - command_class={ - CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, - }, - type={"string"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.SENSOR_ALARM, + CommandClass.INDICATOR, + }, + type={"string"}, + ), ), # generic numeric sensors ZWaveDiscoverySchema( platform="sensor", hint="numeric_sensor", - command_class={ - CommandClass.SENSOR_MULTILEVEL, - CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, - CommandClass.BATTERY, - }, - type={"number"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.SENSOR_MULTILEVEL, + CommandClass.SENSOR_ALARM, + CommandClass.INDICATOR, + CommandClass.BATTERY, + }, + type={"number"}, + ), ), # numeric sensors for Meter CC ZWaveDiscoverySchema( platform="sensor", hint="numeric_sensor", - command_class={ - CommandClass.METER, - }, - type={"number"}, - property={"value"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.METER, + }, + type={"number"}, + property={"value"}, + ), ), # special list sensors (Notification CC) ZWaveDiscoverySchema( platform="sensor", hint="list_sensor", - command_class={ - CommandClass.NOTIFICATION, - }, - type={"number"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={"number"}, + ), + allow_multi=True, ), # sensor for basic CC ZWaveDiscoverySchema( platform="sensor", hint="numeric_sensor", - command_class={ - CommandClass.BASIC, - }, - type={"number"}, - property={"currentValue"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.BASIC, + }, + type={"number"}, + property={"currentValue"}, + ), ), # binary switches ZWaveDiscoverySchema( platform="switch", - command_class={CommandClass.SWITCH_BINARY}, - property={"currentValue"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"} + ), + ), + # binary switch + # barrier operator signaling states + ZWaveDiscoverySchema( + platform="switch", + hint="barrier_event_signaling_state", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BARRIER_OPERATOR}, + property={"signalingState"}, + type={"number"}, + ), ), # cover + # window coverings ZWaveDiscoverySchema( platform="cover", - hint="cover", + hint="window_cover", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", @@ -223,9 +329,25 @@ DISCOVERY_SCHEMAS = [ "Motor Control Class C", "Multiposition Motor", }, - command_class={CommandClass.SWITCH_MULTILEVEL}, - property={"currentValue"}, - type={"number"}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # cover + # motorized barriers + ZWaveDiscoverySchema( + platform="cover", + hint="motorized_barrier", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BARRIER_OPERATOR}, + property={"currentState"}, + type={"number"}, + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.BARRIER_OPERATOR}, + property={"targetState"}, + type={"number"}, + ), + ], ), # fan ZWaveDiscoverySchema( @@ -233,9 +355,24 @@ DISCOVERY_SCHEMAS = [ hint="fan", device_class_generic={"Multilevel Switch"}, device_class_specific={"Fan Switch"}, - command_class={CommandClass.SWITCH_MULTILEVEL}, - property={"currentValue"}, - type={"number"}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # number platform + # valve control for thermostats + ZWaveDiscoverySchema( + platform="number", + hint="Valve control", + device_class_generic={"Thermostat"}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), + # lights + # primary value is the currentValue (brightness) + # catch any device with multilevel CC as light + # NOTE: keep this at the bottom of the discovery scheme, + # to handle all others that need the multilevel CC first + ZWaveDiscoverySchema( + platform="light", + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), ] @@ -243,41 +380,65 @@ DISCOVERY_SCHEMAS = [ @callback def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" + # pylint: disable=too-many-nested-blocks for value in node.values.values(): for schema in DISCOVERY_SCHEMAS: - # check device_class_basic + # check manufacturer_id if ( - schema.device_class_basic is not None - and value.node.device_class.basic not in schema.device_class_basic + schema.manufacturer_id is not None + and value.node.manufacturer_id not in schema.manufacturer_id + ): + continue + # check product_id + if ( + schema.product_id is not None + and value.node.product_id not in schema.product_id + ): + continue + # check product_type + if ( + schema.product_type is not None + and value.node.product_type not in schema.product_type + ): + continue + # check firmware_version + if ( + schema.firmware_version is not None + and value.node.firmware_version not in schema.firmware_version + ): + continue + # check device_class_basic + if not check_device_class( + value.node.device_class.basic, schema.device_class_basic ): continue # check device_class_generic - if ( - schema.device_class_generic is not None - and value.node.device_class.generic not in schema.device_class_generic + if not check_device_class( + value.node.device_class.generic, schema.device_class_generic ): continue # check device_class_specific - if ( - schema.device_class_specific is not None - and value.node.device_class.specific not in schema.device_class_specific + if not check_device_class( + value.node.device_class.specific, schema.device_class_specific ): continue - # check command_class - if ( - schema.command_class is not None - and value.command_class not in schema.command_class - ): - continue - # check endpoint - if schema.endpoint is not None and value.endpoint not in schema.endpoint: - continue - # check property - if schema.property is not None and value.property_ not in schema.property: - continue - # check metadata_type - if schema.type is not None and value.metadata.type not in schema.type: + # check primary value + if not check_value(value, schema.primary_value): continue + # check additional required values + if schema.required_values is not None: + if not all( + any(check_value(val, val_scheme) for val in node.values.values()) + for val_scheme in schema.required_values + ): + continue + # check for values that may not be present + if schema.absent_values is not None: + if any( + any(check_value(val, val_scheme) for val in node.values.values()) + for val_scheme in schema.absent_values + ): + continue # all checks passed, this value belongs to an entity yield ZwaveDiscoveryInfo( node=value.node, @@ -285,3 +446,42 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None platform=schema.platform, platform_hint=schema.hint, ) + if not schema.allow_multi: + # break out of loop, this value may not be discovered by other schemas/platforms + break + + +@callback +def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: + """Check if value matches scheme.""" + # check command_class + if ( + schema.command_class is not None + and value.command_class not in schema.command_class + ): + return False + # check endpoint + if schema.endpoint is not None and value.endpoint not in schema.endpoint: + return False + # check property + if schema.property is not None and value.property_ not in schema.property: + return False + # check metadata_type + if schema.type is not None and value.metadata.type not in schema.type: + return False + return True + + +@callback +def check_device_class( + device_class: DeviceClassItem, required_value: Optional[Set[Union[str, int]]] +) -> bool: + """Check if device class id or label matches.""" + if required_value is None: + return True + for val in required_value: + if isinstance(val, str) and device_class.label == val: + return True + if isinstance(val, int) and device_class.key == val: + return True + return False diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index a17e43e2f23..d0ed9eb5291 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,30 +1,25 @@ """Generic Z-Wave Entity Class.""" import logging -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Union from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.model.node import Node as ZwaveNode 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 .const import DOMAIN from .discovery import ZwaveDiscoveryInfo +from .helpers import get_device_id, get_unique_id LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" -@callback -def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]: - """Get device registry identifier for Z-Wave node.""" - return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") - - class ZWaveBaseEntity(Entity): """Generic Entity Class for a Z-Wave Device.""" @@ -36,6 +31,9 @@ class ZWaveBaseEntity(Entity): self.client = client self.info = info self._name = self.generate_name() + self._unique_id = get_unique_id( + self.client.driver.controller.home_id, self.info.primary_value.value_id + ) # entities requiring additional values, can add extra ids to this list self.watched_value_ids = {self.info.primary_value.value_id} @@ -46,6 +44,35 @@ class ZWaveBaseEntity(Entity): To be overridden by platforms needing this event. """ + async def async_poll_value(self, refresh_all_values: bool) -> None: + """Poll a value.""" + assert self.hass + if not refresh_all_values: + self.hass.async_create_task( + self.info.node.async_poll_value(self.info.primary_value) + ) + LOGGER.info( + ( + "Refreshing primary value %s for %s, " + "state update may be delayed for devices on battery" + ), + self.info.primary_value, + self.entity_id, + ) + return + + for value_id in self.watched_value_ids: + self.hass.async_create_task(self.info.node.async_poll_value(value_id)) + + LOGGER.info( + ( + "Refreshing values %s for %s, state update may be delayed for " + "devices on battery" + ), + ", ".join(self.watched_value_ids), + self.entity_id, + ) + async def async_added_to_hass(self) -> None: """Call when entity is added.""" assert self.hass # typing @@ -53,6 +80,13 @@ class ZWaveBaseEntity(Entity): self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) @property def device_info(self) -> dict: @@ -64,20 +98,22 @@ class ZWaveBaseEntity(Entity): def generate_name( self, + include_value_name: bool = False, alternate_value_name: Optional[str] = None, additional_info: Optional[List[str]] = None, ) -> str: """Generate entity name.""" if additional_info is None: additional_info = [] - node_name = self.info.node.name or self.info.node.device_config.description - value_name = ( - alternate_value_name - or self.info.primary_value.metadata.label - or self.info.primary_value.property_key_name - or self.info.primary_value.property_name - ) - name = f"{node_name}: {value_name}" + name: str = self.info.node.name or self.info.node.device_config.description + if include_value_name: + value_name = ( + alternate_value_name + or self.info.primary_value.metadata.label + or self.info.primary_value.property_key_name + or self.info.primary_value.property_name + ) + name = f"{name}: {value_name}" for item in additional_info: if item: name += f" - {item}" @@ -95,7 +131,7 @@ class ZWaveBaseEntity(Entity): @property def unique_id(self) -> str: """Return the unique_id of the entity.""" - return f"{self.client.driver.controller.home_id}.{self.info.value_id}" + return self._unique_id @property def available(self) -> bool: @@ -132,6 +168,7 @@ class ZWaveBaseEntity(Entity): value_property: Union[str, int], command_class: Optional[int] = None, endpoint: Optional[int] = None, + value_property_key: Optional[int] = None, value_property_key_name: Optional[str] = None, add_to_watched_value_ids: bool = True, check_all_endpoints: bool = False, @@ -144,16 +181,14 @@ class ZWaveBaseEntity(Entity): if endpoint is None: endpoint = self.info.primary_value.endpoint - # Build partial event data dictionary so we can change the endpoint later - partial_evt_data = { - "commandClass": command_class, - "property": value_property, - "propertyKeyName": value_property_key_name, - } - # lookup value by value_id value_id = get_value_id( - self.info.node, {**partial_evt_data, "endpoint": endpoint} + self.info.node, + command_class, + value_property, + endpoint=endpoint, + property_key=value_property_key, + property_key_name=value_property_key_name, ) return_value = self.info.node.values.get(value_id) @@ -164,7 +199,11 @@ class ZWaveBaseEntity(Entity): if endpoint_.index != self.info.primary_value.endpoint: value_id = get_value_id( self.info.node, - {**partial_evt_data, "endpoint": endpoint_.index}, + command_class, + value_property, + endpoint=endpoint_.index, + property_key=value_property_key, + property_key_name=value_property_key_name, ) return_value = self.info.node.values.get(value_id) if return_value: diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 360f907e74a..ea17fbe4cff 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -1,5 +1,4 @@ """Support for Z-Wave fans.""" -import logging import math from typing import Any, Callable, List, Optional @@ -7,29 +6,25 @@ from zwave_js_server.client import Client as ZwaveClient from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_SET_SPEED, FanEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity -_LOGGER = logging.getLogger(__name__) - SUPPORTED_FEATURES = SUPPORT_SET_SPEED -# Value will first be divided to an integer -VALUE_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} -SPEED_TO_VALUE = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 50, SPEED_HIGH: 99} -SPEED_LIST = [*SPEED_TO_VALUE] +SPEED_RANGE = (1, 99) # off is not included async def async_setup_entry( @@ -57,29 +52,29 @@ async def async_setup_entry( class ZwaveFan(ZWaveBaseEntity, FanEntity): """Representation of a Z-Wave fan.""" - def __init__( - self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo - ) -> None: - """Initialize the fan.""" - super().__init__(config_entry, client, info) - self._previous_speed: Optional[str] = None - - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if speed not in SPEED_TO_VALUE: - raise ValueError(f"Invalid speed received: {speed}") - self._previous_speed = speed + async def async_set_percentage(self, percentage: Optional[int]) -> None: + """Set the speed percentage of the fan.""" target_value = self.get_zwave_value("targetValue") - await self.info.node.async_set_value(target_value, SPEED_TO_VALUE[speed]) - async def async_turn_on(self, speed: Optional[str] = None, **kwargs: Any) -> None: - """Turn the device on.""" - if speed is None: + if percentage is None: # Value 255 tells device to return to previous value - target_value = self.get_zwave_value("targetValue") - await self.info.node.async_set_value(target_value, 255) + zwave_speed = 255 + elif percentage == 0: + zwave_speed = 0 else: - await self.async_set_speed(speed) + zwave_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + + await self.info.node.async_set_value(target_value, zwave_speed) + + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs: Any, + ) -> None: + """Turn the device on.""" + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -95,23 +90,17 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): return bool(self.info.primary_value.value > 0) @property - def speed(self) -> Optional[str]: - """Return the current speed. - - The Z-Wave speed value is a byte 0-255. 255 means previous value. - The normal range of the speed is 0-99. 0 means off. - """ + def percentage(self) -> Optional[int]: + """Return the current speed percentage.""" if self.info.primary_value.value is None: # guard missing value return None - - value = math.ceil(self.info.primary_value.value * 3 / 100) - return VALUE_TO_SPEED.get(value, self._previous_speed) + return ranged_value_to_percentage(SPEED_RANGE, self.info.primary_value.value) @property - def speed_list(self) -> List[str]: - """Get the list of available speeds.""" - return SPEED_LIST + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) @property def supported_features(self) -> int: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py new file mode 100644 index 00000000000..9582b7ee054 --- /dev/null +++ b/homeassistant/components/zwave_js/helpers.py @@ -0,0 +1,117 @@ +"""Helper functions for Z-Wave JS integration.""" +from typing import List, Tuple, cast + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.value import Value as ZwaveValue + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg + +from .const import DATA_CLIENT, DOMAIN + + +@callback +def get_old_value_id(value: ZwaveValue) -> str: + """Get old value ID so we can migrate entity unique ID.""" + command_class = value.command_class + endpoint = value.endpoint or "00" + property_ = value.property_ + property_key_name = value.property_key_name or "00" + return f"{value.node.node_id}-{command_class}-{endpoint}-{property_}-{property_key_name}" + + +@callback +def get_unique_id(home_id: str, value_id: str) -> str: + """Get unique ID from home ID and value ID.""" + return f"{home_id}.{value_id}" + + +@callback +def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]: + """Get device registry identifier for Z-Wave node.""" + return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") + + +@callback +def get_home_and_node_id_from_device_id(device_id: Tuple[str, str]) -> List[str]: + """ + Get home ID and node ID for Z-Wave device registry entry. + + Returns [home_id, node_id] + """ + return device_id[1].split("-") + + +@callback +def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveNode: + """ + Get node from a device ID. + + Raises ValueError if device is invalid or node can't be found. + """ + device_entry = async_get_dev_reg(hass).async_get(device_id) + + if not device_entry: + raise ValueError("Device ID is not valid") + + # Use device config entry ID's to validate that this is a valid zwave_js device + # and to get the client + config_entry_ids = device_entry.config_entries + config_entry_id = next( + ( + config_entry_id + for config_entry_id in config_entry_ids + if cast( + ConfigEntry, + hass.config_entries.async_get_entry(config_entry_id), + ).domain + == DOMAIN + ), + None, + ) + if config_entry_id is None or config_entry_id not in hass.data[DOMAIN]: + raise ValueError("Device is not from an existing zwave_js config entry") + + client = hass.data[DOMAIN][config_entry_id][DATA_CLIENT] + + # Get node ID from device identifier, perform some validation, and then get the + # node + identifier = next( + ( + get_home_and_node_id_from_device_id(identifier) + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + + node_id = int(identifier[1]) if identifier is not None else None + + if node_id is None or node_id not in client.driver.controller.nodes: + raise ValueError("Device node can't be found") + + return client.driver.controller.nodes[node_id] + + +@callback +def async_get_node_from_entity_id(hass: HomeAssistant, entity_id: str) -> ZwaveNode: + """ + Get node from an entity ID. + + Raises ValueError if entity is invalid. + """ + entity_entry = async_get_ent_reg(hass).async_get(entity_id) + + if not entity_entry: + raise ValueError("Entity ID is not valid") + + if entity_entry.platform != DOMAIN: + raise ValueError("Entity is not from zwave_js integration") + + # Assert for mypy, safe because we know that zwave_js entities are always + # tied to a device + assert entity_entry.device_id + return async_get_node_from_device_id(hass, entity_entry.device_id) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index dd444fdb40d..d9c31210bea 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -1,9 +1,9 @@ """Support for Z-Wave lights.""" import logging -from typing import Any, Callable, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass +from zwave_js_server.const import ColorComponent, CommandClass from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -30,6 +30,17 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) +MULTI_COLOR_MAP = { + ColorComponent.WARM_WHITE: "warmWhite", + ColorComponent.COLD_WHITE: "coldWhite", + ColorComponent.RED: "red", + ColorComponent.GREEN: "green", + ColorComponent.BLUE: "blue", + ColorComponent.AMBER: "amber", + ColorComponent.CYAN: "cyan", + ColorComponent.PURPLE: "purple", +} + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable @@ -149,21 +160,21 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # RGB/HS color hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color is not None and self._supports_color: - # set white levels to 0 when setting rgb - await self._async_set_color("Warm White", 0) - await self._async_set_color("Cold White", 0) red, green, blue = color_util.color_hs_to_RGB(*hs_color) - await self._async_set_color("Red", red) - await self._async_set_color("Green", green) - await self._async_set_color("Blue", blue) + colors = { + ColorComponent.RED: red, + ColorComponent.GREEN: green, + ColorComponent.BLUE: blue, + } + if self._supports_color_temp: + # turn of white leds when setting rgb + colors[ColorComponent.WARM_WHITE] = 0 + colors[ColorComponent.COLD_WHITE] = 0 + await self._async_set_colors(colors) # Color temperature color_temp = kwargs.get(ATTR_COLOR_TEMP) if color_temp is not None and self._supports_color_temp: - # turn off rgb when setting white values - await self._async_set_color("Red", 0) - await self._async_set_color("Green", 0) - await self._async_set_color("Blue", 0) # Limit color temp to min/max values cold = max( 0, @@ -177,17 +188,28 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ), ) warm = 255 - cold - await self._async_set_color("Warm White", warm) - await self._async_set_color("Cold White", cold) + await self._async_set_colors( + { + # turn off color leds when setting color temperature + ColorComponent.RED: 0, + ColorComponent.GREEN: 0, + ColorComponent.BLUE: 0, + ColorComponent.WARM_WHITE: warm, + ColorComponent.COLD_WHITE: cold, + } + ) # White value white_value = kwargs.get(ATTR_WHITE_VALUE) if white_value is not None and self._supports_white_value: - # turn off rgb when setting white values - await self._async_set_color("Red", 0) - await self._async_set_color("Green", 0) - await self._async_set_color("Blue", 0) - await self._async_set_color("Warm White", white_value) + # white led brightness is controlled by white level + # rgb leds (if any) can be on at the same time + await self._async_set_colors( + { + ColorComponent.WARM_WHITE: white_value, + ColorComponent.COLD_WHITE: white_value, + } + ) # set brightness await self._async_set_brightness( @@ -198,23 +220,42 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): """Turn the light off.""" await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) - async def _async_set_color(self, color_name: str, new_value: int) -> None: - """Set defined color to given value.""" - cur_zwave_value = self.get_zwave_value( - "currentColor", + async def _async_set_colors(self, colors: Dict[ColorComponent, int]) -> None: + """Set (multiple) defined colors to given value(s).""" + # prefer the (new) combined color property + # https://github.com/zwave-js/node-zwave-js/pull/1782 + combined_color_val = self.get_zwave_value( + "targetColor", CommandClass.SWITCH_COLOR, - value_property_key_name=color_name, + value_property_key=None, + value_property_key_name=None, ) - # guard for unsupported command - if cur_zwave_value is None: + if combined_color_val and isinstance(combined_color_val.value, dict): + colors_dict = {} + for color, value in colors.items(): + color_name = MULTI_COLOR_MAP[color] + colors_dict[color_name] = value + # set updated color object + await self.info.node.async_set_value(combined_color_val, colors_dict) return + + # fallback to setting the color(s) one by one if multicolor fails + # not sure this is needed at all, but just in case + for color, value in colors.items(): + await self._async_set_color(color, value) + + async def _async_set_color(self, color: ColorComponent, new_value: int) -> None: + """Set defined color to given value.""" + property_key = color.value # actually set the new color value target_zwave_value = self.get_zwave_value( "targetColor", CommandClass.SWITCH_COLOR, - value_property_key_name=color_name, + value_property_key=property_key.key, + value_property_key_name=property_key.name, ) if target_zwave_value is None: + # guard for unsupported color return await self.info.node.async_set_value(target_zwave_value, new_value) @@ -222,9 +263,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self, brightness: Optional[int], transition: Optional[int] = None ) -> None: """Set new brightness to light.""" - if brightness is None and self.info.primary_value.value: - # there is no point in setting default brightness when light is already on - return if brightness is None: # Level 255 means to set it to previous value. zwave_brightness = 255 @@ -273,57 +311,80 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @callback def _calculate_color_values(self) -> None: """Calculate light colors.""" - - # RGB support + # NOTE: We lookup all values here (instead of relying on the multicolor one) + # to find out what colors are supported + # as this is a simple lookup by key, this not heavy red_val = self.get_zwave_value( - "currentColor", CommandClass.SWITCH_COLOR, value_property_key_name="Red" + "currentColor", + CommandClass.SWITCH_COLOR, + value_property_key=ColorComponent.RED.value.key, + value_property_key_name=ColorComponent.RED.value.name, ) green_val = self.get_zwave_value( - "currentColor", CommandClass.SWITCH_COLOR, value_property_key_name="Green" + "currentColor", + CommandClass.SWITCH_COLOR, + value_property_key=ColorComponent.GREEN.value.key, + value_property_key_name=ColorComponent.GREEN.value.name, ) blue_val = self.get_zwave_value( - "currentColor", CommandClass.SWITCH_COLOR, value_property_key_name="Blue" + "currentColor", + CommandClass.SWITCH_COLOR, + value_property_key=ColorComponent.BLUE.value.key, + value_property_key_name=ColorComponent.BLUE.value.name, ) - if red_val and green_val and blue_val: - self._supports_color = True - # convert to HS - if ( - red_val.value is not None - and green_val.value is not None - and blue_val.value is not None - ): - self._hs_color = color_util.color_RGB_to_hs( - red_val.value, green_val.value, blue_val.value - ) - - # White colors ww_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, - value_property_key_name="Warm White", + value_property_key=ColorComponent.WARM_WHITE.value.key, + value_property_key_name=ColorComponent.WARM_WHITE.value.name, ) cw_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, - value_property_key_name="Cold White", + value_property_key=ColorComponent.COLD_WHITE.value.key, + value_property_key_name=ColorComponent.COLD_WHITE.value.name, ) + # prefer the (new) combined color property + # https://github.com/zwave-js/node-zwave-js/pull/1782 + combined_color_val = self.get_zwave_value( + "currentColor", + CommandClass.SWITCH_COLOR, + value_property_key=None, + value_property_key_name=None, + ) + if combined_color_val and isinstance(combined_color_val.value, dict): + multi_color = combined_color_val.value + else: + multi_color = {} + + # RGB support + if red_val and green_val and blue_val: + # prefer values from the multicolor property + red = multi_color.get("red", red_val.value) + green = multi_color.get("green", green_val.value) + blue = multi_color.get("blue", blue_val.value) + self._supports_color = True + # convert to HS + self._hs_color = color_util.color_RGB_to_hs(red, green, blue) + + # color temperature support if ww_val and cw_val: - # Color temperature (CW + WW) Support self._supports_color_temp = True + warm_white = multi_color.get("warmWhite", ww_val.value) + cold_white = multi_color.get("coldWhite", cw_val.value) # Calculate color temps based on whites - cold_level = cw_val.value or 0 - if cold_level or ww_val.value is not None: + if cold_white or warm_white: self._color_temp = round( self._max_mireds - - ((cold_level / 255) * (self._max_mireds - self._min_mireds)) + - ((cold_white / 255) * (self._max_mireds - self._min_mireds)) ) else: self._color_temp = None + # only one white channel (warm white) = white_level support elif ww_val: - # only one white channel (warm white) self._supports_white_value = True - self._white_value = ww_val.value + self._white_value = multi_color.get("warmWhite", ww_val.value) + # only one white channel (cool white) = white_level support elif cw_val: - # only one white channel (cool white) self._supports_white_value = True - self._white_value = cw_val.value + self._white_value = multi_color.get("coldWhite", cw_val.value) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4bd12baa685..c812515a179 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.17.2"], + "requirements": ["zwave-js-server-python==0.20.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"] } diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py new file mode 100644 index 00000000000..8f8e894cda2 --- /dev/null +++ b/homeassistant/components/zwave_js/number.py @@ -0,0 +1,84 @@ +"""Support for Z-Wave controls using the number platform.""" +from typing import Callable, List, Optional + +from zwave_js_server.client import Client as ZwaveClient + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up Z-Wave Number entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_number(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave number entity.""" + entities: List[ZWaveBaseEntity] = [] + entities.append(ZwaveNumberEntity(config_entry, client, info)) + async_add_entities(entities) + + hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{NUMBER_DOMAIN}", + async_add_number, + ) + ) + + +class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): + """Representation of a Z-Wave number entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveNumberEntity entity.""" + super().__init__(config_entry, client, info) + self._name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) + if self.info.primary_value.metadata.writeable: + self._target_value = self.info.primary_value + else: + self._target_value = self.get_zwave_value("targetValue") + + @property + def min_value(self) -> float: + """Return the minimum value.""" + if self.info.primary_value.metadata.min is None: + return 0 + return float(self.info.primary_value.metadata.min) + + @property + def max_value(self) -> float: + """Return the maximum value.""" + if self.info.primary_value.metadata.max is None: + return 255 + return float(self.info.primary_value.metadata.max) + + @property + def value(self) -> Optional[float]: # type: ignore + """Return the entity value.""" + if self.info.primary_value.value is None: + return None + return float(self.info.primary_value.value) + + @property + def unit_of_measurement(self) -> Optional[str]: + """Return the unit of measurement of this entity, if any.""" + if self.info.primary_value.metadata.unit is None: + return None + return str(self.info.primary_value.metadata.unit) + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + await self.info.node.async_set_value(self._target_value, value) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 78b536b81f7..8e22323c733 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -64,6 +64,16 @@ async def async_setup_entry( class ZwaveSensorBase(ZWaveBaseEntity): """Basic Representation of a Z-Wave sensor.""" + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveSensorBase entity.""" + super().__init__(config_entry, client, info) + self._name = self.generate_name(include_value_name=True) + @property def device_class(self) -> Optional[str]: """Return the device class of the sensor.""" @@ -132,7 +142,10 @@ class ZWaveNumericSensor(ZwaveSensorBase): """Initialize a ZWaveNumericSensor entity.""" super().__init__(config_entry, client, info) if self.info.primary_value.command_class == CommandClass.BASIC: - self._name = self.generate_name(self.info.primary_value.command_class_name) + self._name = self.generate_name( + include_value_name=True, + alternate_value_name=self.info.primary_value.command_class_name, + ) @property def state(self) -> float: @@ -166,8 +179,9 @@ class ZWaveListSensor(ZwaveSensorBase): """Initialize a ZWaveListSensor entity.""" super().__init__(config_entry, client, info) self._name = self.generate_name( - self.info.primary_value.property_name, - [self.info.primary_value.property_key_name], + include_value_name=True, + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.property_key_name], ) @property diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py new file mode 100644 index 00000000000..c971891b35b --- /dev/null +++ b/homeassistant/components/zwave_js/services.py @@ -0,0 +1,139 @@ +"""Methods and classes related to executing Z-Wave commands and publishing these to hass.""" + +import logging +from typing import Dict, Set, Union + +import voluptuous as vol +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.util.node import async_set_config_parameter + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import const +from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id + +_LOGGER = logging.getLogger(__name__) + + +def parameter_name_does_not_need_bitmask( + val: Dict[str, Union[int, str]] +) -> Dict[str, Union[int, str]]: + """Validate that if a parameter name is provided, bitmask is not as well.""" + if isinstance(val[const.ATTR_CONFIG_PARAMETER], str) and ( + val.get(const.ATTR_CONFIG_PARAMETER_BITMASK) + ): + raise vol.Invalid( + "Don't include a bitmask when a parameter name is specified", + path=[const.ATTR_CONFIG_PARAMETER, const.ATTR_CONFIG_PARAMETER_BITMASK], + ) + return val + + +# Validates that a bitmask is provided in hex form and converts it to decimal +# int equivalent since that's what the library uses +BITMASK_SCHEMA = vol.All( + cv.string, vol.Lower, vol.Match(r"^(0x)?[0-9a-f]+$"), lambda value: int(value, 16) +) + + +class ZWaveServices: + """Class that holds our services (Zwave Commands) that should be published to hass.""" + + def __init__(self, hass: HomeAssistant, ent_reg: EntityRegistry): + """Initialize with hass object.""" + self._hass = hass + self._ent_reg = ent_reg + + @callback + def async_register(self) -> None: + """Register all our services.""" + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_SET_CONFIG_PARAMETER, + self.async_set_config_parameter, + schema=vol.All( + { + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any( + vol.Coerce(int), cv.string + ), + vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( + vol.Coerce(int), BITMASK_SCHEMA + ), + vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( + vol.Coerce(int), cv.string + ), + }, + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + parameter_name_does_not_need_bitmask, + ), + ) + + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_REFRESH_VALUE, + self.async_poll_value, + schema=vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_REFRESH_ALL_VALUES, default=False): bool, + } + ), + ) + + async def async_set_config_parameter(self, service: ServiceCall) -> None: + """Set a config value on a node.""" + nodes: Set[ZwaveNode] = set() + if ATTR_ENTITY_ID in service.data: + nodes |= { + async_get_node_from_entity_id(self._hass, entity_id) + for entity_id in service.data[ATTR_ENTITY_ID] + } + if ATTR_DEVICE_ID in service.data: + nodes |= { + async_get_node_from_device_id(self._hass, device_id) + for device_id in service.data[ATTR_DEVICE_ID] + } + property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] + property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) + new_value = service.data[const.ATTR_CONFIG_VALUE] + + for node in nodes: + zwave_value = await async_set_config_parameter( + node, + new_value, + property_or_property_name, + property_key=property_key, + ) + + if zwave_value: + _LOGGER.info( + "Set configuration parameter %s on Node %s with value %s", + zwave_value, + node, + new_value, + ) + else: + raise ValueError( + f"Unable to set configuration parameter on Node {node} with " + f"value {new_value}" + ) + + async def async_poll_value(self, service: ServiceCall) -> None: + """Poll value on a node.""" + for entity_id in service.data[ATTR_ENTITY_ID]: + entry = self._ent_reg.async_get(entity_id) + if entry is None or entry.platform != const.DOMAIN: + raise ValueError( + f"Entity {entity_id} is not a valid {const.DOMAIN} entity." + ) + async_dispatcher_send( + self._hass, + f"{const.DOMAIN}_{entry.unique_id}_poll_value", + service.data[const.ATTR_REFRESH_ALL_VALUES], + ) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index cc81da7ed58..8e6d907fc96 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -1,24 +1,84 @@ # Describes the format for available Z-Wave services clear_lock_usercode: - description: Clear a usercode from lock. + name: Clear a usercode from a lock + description: Clear a usercode from a lock + target: + entity: + domain: lock + integration: zwave_js fields: - entity_id: - description: Lock entity_id. - example: lock.front_door_locked code_slot: - description: Code slot to clear code from. + name: Code slot + description: Code slot to clear code from + required: true example: 1 + selector: + text: set_lock_usercode: - description: Set a usercode to lock. + name: Set a usercode on a lock + description: Set a usercode on a lock + target: + entity: + domain: lock + integration: zwave_js fields: - entity_id: - description: Lock entity_id. - example: lock.front_door_locked code_slot: + name: Code slot description: Code slot to set the code. + required: true example: 1 + selector: + text: usercode: + name: Code description: Code to set. + required: true example: 1234 + selector: + text: + +set_config_parameter: + name: Set a Z-Wave device configuration parameter + description: Allow for changing configuration parameters of your Z-Wave devices. + target: + entity: + integration: zwave_js + fields: + parameter: + name: Parameter + description: The (name or id of the) configuration parameter you want to configure. + example: Minimum brightness level + required: true + selector: + text: + value: + name: Value + description: The new value to set for this configuration parameter. + example: 5 + required: true + selector: + object: + bitmask: + name: Bitmask + description: Target a specific bitmask (see the documentation for more information). + advanced: true + selector: + object: + +refresh_value: + name: Refresh value(s) of a Z-Wave entity + description: Force update value(s) for a Z-Wave entity + target: + entity: + integration: zwave_js + fields: + refresh_all_values: + name: Refresh all values? + description: Whether to refresh all values (true) or just the primary value (false) + required: false + example: true + default: false + selector: + boolean: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 212bef70889..eb13ad512e3 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,13 +15,14 @@ "install_addon": { "title": "The Z-Wave JS add-on installation has started" }, - "start_addon": { + "configure_addon": { "title": "Enter the Z-Wave JS add-on configuration", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", "network_key": "Network Key" } }, + "start_addon": { "title": "The Z-Wave JS add-on is starting." }, "hassio_confirm": { "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" } @@ -38,12 +39,13 @@ "addon_info_failed": "Failed to get Z-Wave JS add-on info.", "addon_install_failed": "Failed to install the Z-Wave JS add-on.", "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "addon_missing_discovery_info": "Missing Z-Wave JS add-on discovery info.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes." + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." } } } diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 8feba5911f8..a325e9821f7 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -17,6 +17,10 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) +BARRIER_EVENT_SIGNALING_OFF = 0 +BARRIER_EVENT_SIGNALING_ON = 255 + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable ) -> None: @@ -27,7 +31,12 @@ async def async_setup_entry( def async_add_switch(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave Switch.""" entities: List[ZWaveBaseEntity] = [] - entities.append(ZWaveSwitch(config_entry, client, info)) + if info.platform_hint == "barrier_event_signaling_state": + entities.append( + ZWaveBarrierEventSignalingSwitch(config_entry, client, info) + ) + else: + entities.append(ZWaveSwitch(config_entry, client, info)) async_add_entities(entities) @@ -62,3 +71,59 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): target_value = self.get_zwave_value("targetValue") if target_value is not None: await self.info.node.async_set_value(target_value, False) + + +class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): + """This switch is used to turn on or off a barrier device's event signaling subsystem.""" + + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveBarrierEventSignalingSwitch entity.""" + super().__init__(config_entry, client, info) + self._name = self.generate_name(include_value_name=True) + self._state: Optional[bool] = None + + self._update_state() + + @callback + def on_value_update(self) -> None: + """Call when a watched value is added or updated.""" + self._update_state() + + @property + def name(self) -> str: + """Return default name from device name and value name combination.""" + return self._name + + @property + def is_on(self) -> Optional[bool]: # type: ignore + """Return a boolean for the state of the switch.""" + return self._state + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.info.node.async_set_value( + self.info.primary_value, BARRIER_EVENT_SIGNALING_ON + ) + # this value is not refreshed, so assume success + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.info.node.async_set_value( + self.info.primary_value, BARRIER_EVENT_SIGNALING_OFF + ) + # this value is not refreshed, so assume success + self._state = False + self.async_write_ha_state() + + @callback + def _update_state(self) -> None: + self._state = None + if self.info.primary_value.value is not None: + self._state = self.info.primary_value.value == BARRIER_EVENT_SIGNALING_ON diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index 93ec53a644e..731c0bbcea8 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -6,6 +6,7 @@ "addon_install_failed": "No s'ha pogut instal\u00b7lar el complement Z-Wave JS.", "addon_missing_discovery_info": "Falta la informaci\u00f3 de descobriment del complement Z-Wave JS.", "addon_set_config_failed": "No s'ha pogut establir la configuraci\u00f3 de Z-Wave JS.", + "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS.", "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" @@ -17,9 +18,17 @@ "unknown": "Error inesperat" }, "progress": { - "install_addon": "Espera mentre finalitza la instal\u00b7laci\u00f3 del complement Z-Wave JS. Pot tardar uns quants minuts." + "install_addon": "Espera mentre finalitza la instal\u00b7laci\u00f3 del complement Z-Wave JS. Pot tardar uns quants minuts.", + "start_addon": "Espera mentre es completa la inicialitzaci\u00f3 del complement Z-Wave JS. Pot tardar uns segons." }, "step": { + "configure_addon": { + "data": { + "network_key": "Clau de xarxa", + "usb_path": "Ruta del port USB del dispositiu" + }, + "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" + }, "hassio_confirm": { "title": "Configura la integraci\u00f3 Z-Wave JS mitjan\u00e7ant el complement Z-Wave JS" }, @@ -39,11 +48,7 @@ "title": "Selecciona el m\u00e8tode de connexi\u00f3" }, "start_addon": { - "data": { - "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" - }, - "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" + "title": "El complement Z-Wave JS s'est\u00e0 iniciant." }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/cs.json b/homeassistant/components/zwave_js/translations/cs.json index 96073b579ed..57e7a6b74db 100644 --- a/homeassistant/components/zwave_js/translations/cs.json +++ b/homeassistant/components/zwave_js/translations/cs.json @@ -10,16 +10,16 @@ "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "step": { + "configure_addon": { + "data": { + "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, "manual": { "data": { "url": "URL" } }, - "start_addon": { - "data": { - "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" - } - }, "user": { "data": { "url": "URL" diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index d4903bc8c6d..9ff130605ef 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -1,13 +1,25 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, "step": { + "configure_addon": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + } + }, + "manual": { + "data": { + "url": "URL" + } + }, "user": { "data": { "url": "URL" diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 977651a576b..101942dc717 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -4,8 +4,8 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", "addon_info_failed": "Failed to get Z-Wave JS add-on info.", "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_missing_discovery_info": "Missing Z-Wave JS add-on discovery info.", "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", "cannot_connect": "Failed to connect" @@ -17,9 +17,17 @@ "unknown": "Unexpected error" }, "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes." + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." }, "step": { + "configure_addon": { + "data": { + "network_key": "Network Key", + "usb_path": "USB Device Path" + }, + "title": "Enter the Z-Wave JS add-on configuration" + }, "hassio_confirm": { "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" }, @@ -39,16 +47,7 @@ "title": "Select connection method" }, "start_addon": { - "data": { - "network_key": "Network Key", - "usb_path": "USB Device Path" - }, - "title": "Enter the Z-Wave JS add-on configuration" - }, - "user": { - "data": { - "url": "URL" - } + "title": "The Z-Wave JS add-on is starting." } } }, diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index e5ee009c0d1..26fd155a0ad 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -1,29 +1,54 @@ { "config": { "abort": { + "addon_get_discovery_info_failed": "Fallo en la obtenci\u00f3n de la informaci\u00f3n de descubrimiento del complemento Z-Wave JS.", + "addon_info_failed": "No se pudo obtener la informaci\u00f3n del complemento Z-Wave JS.", + "addon_install_failed": "No se ha podido instalar el complemento Z-Wave JS.", + "addon_missing_discovery_info": "Falta informaci\u00f3n de descubrimiento del complemento Z-Wave JS.", + "addon_set_config_failed": "Fallo en la configuraci\u00f3n de Z-Wave JS.", + "addon_start_failed": "No se ha podido iniciar el complemento Z-Wave JS.", "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", "cannot_connect": "No se pudo conectar" }, "error": { + "addon_start_failed": "No se pudo iniciar el complemento Z-Wave JS. Comprueba la configuraci\u00f3n.", "cannot_connect": "No se pudo conectar", "invalid_ws_url": "URL de websocket no v\u00e1lida", "unknown": "Error inesperado" }, + "progress": { + "install_addon": "Espera mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Puede tardar varios minutos.", + "start_addon": "Espere mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar unos segundos." + }, "step": { + "configure_addon": { + "data": { + "network_key": "Clave de red", + "usb_path": "Ruta del dispositivo USB" + }, + "title": "Introduzca la configuraci\u00f3n del complemento Z-Wave JS" + }, + "hassio_confirm": { + "title": "Configurar la integraci\u00f3n de Z-Wave JS con el complemento Z-Wave JS" + }, + "install_addon": { + "title": "La instalaci\u00f3n del complemento Z-Wave JS ha comenzado" + }, "manual": { "data": { "url": "URL" } }, "on_supervisor": { + "data": { + "use_addon": "Usar el complemento Z-Wave JS Supervisor" + }, + "description": "\u00bfQuieres utilizar el complemento Z-Wave JS Supervisor?", "title": "Selecciona el m\u00e9todo de conexi\u00f3n" }, "start_addon": { - "data": { - "network_key": "Clave de red", - "usb_path": "Ruta del dispositivo USB" - } + "title": "Se est\u00e1 iniciando el complemento Z-Wave JS." }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index 7a7aadfb841..4c68e63530f 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -6,6 +6,7 @@ "addon_install_failed": "Z-Wave JS lisandmooduli paigaldamine nurjus.", "addon_missing_discovery_info": "Z-Wave JS lisandmooduli tuvastusteave puudub.", "addon_set_config_failed": "Z-Wave JS konfiguratsiooni m\u00e4\u00e4ramine nurjus.", + "addon_start_failed": "Z-Wave JS-i lisandmooduli k\u00e4ivitamine nurjus.", "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", "cannot_connect": "\u00dchendamine nurjus" @@ -17,9 +18,17 @@ "unknown": "Ootamatu t\u00f5rge" }, "progress": { - "install_addon": "Palun oota kuni Z-Wave JS lisandmoodul on paigaldatud. See v\u00f5ib v\u00f5tta mitu minutit." + "install_addon": "Palun oota kuni Z-Wave JS lisandmoodul on paigaldatud. See v\u00f5ib v\u00f5tta mitu minutit.", + "start_addon": "Palun oota kuni Z-Wave JS lisandmooduli ak\u00e4ivitumine l\u00f5ppeb. See v\u00f5ib v\u00f5tta m\u00f5ned sekundid." }, "step": { + "configure_addon": { + "data": { + "network_key": "V\u00f5rgu v\u00f5ti", + "usb_path": "USB-seadme asukoha rada" + }, + "title": "Sisesta Z-Wave JS lisandmooduli seaded" + }, "hassio_confirm": { "title": "Seadista Z-Wave JS-i sidumine Z-Wave JS-i lisandmooduliga" }, @@ -39,11 +48,7 @@ "title": "Vali \u00fchendusviis" }, "start_addon": { - "data": { - "network_key": "V\u00f5rgu v\u00f5ti", - "usb_path": "USB-seadme asukoha rada" - }, - "title": "Sisesta Z-Wave JS lisandmooduli seaded" + "title": "Z-Wave JS lisandmoodul k\u00e4ivitub." }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index f3a9aff1a29..9cc8bf822b8 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -1,14 +1,55 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9" + "addon_get_discovery_info_failed": "Impossible d'obtenir les informations de d\u00e9couverte du module compl\u00e9mentaire Z-Wave JS.", + "addon_info_failed": "Impossible d'obtenir les informations sur le module compl\u00e9mentaire Z-Wave JS.", + "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire Z-Wave JS.", + "addon_missing_discovery_info": "Informations manquantes sur la d\u00e9couverte du module compl\u00e9mentaire Z-Wave JS.", + "addon_set_config_failed": "\u00c9chec de la d\u00e9finition de la configuration Z-Wave JS.", + "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS.", + "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de la connexion " }, "error": { + "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", "cannot_connect": "Erreur de connection", "invalid_ws_url": "URL websocket invalide", "unknown": "Erreur inattendue" }, + "progress": { + "install_addon": "Veuillez patienter pendant l'installation du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre plusieurs minutes.", + "start_addon": "Veuillez patienter pendant le d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre quelques secondes." + }, "step": { + "configure_addon": { + "data": { + "network_key": "Cl\u00e9 r\u00e9seau", + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "title": "Entrez la configuration du module compl\u00e9mentaire Z-Wave JS" + }, + "hassio_confirm": { + "title": "Configurer l'int\u00e9gration Z-Wave JS avec le module compl\u00e9mentaire Z-Wave JS" + }, + "install_addon": { + "title": "L'installation du module compl\u00e9mentaire Z-Wave JS a d\u00e9marr\u00e9" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor" + }, + "description": "Voulez-vous utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor?", + "title": "S\u00e9lectionner la m\u00e9thode de connexion" + }, + "start_addon": { + "title": "Le module compl\u00e9mentaire Z-Wave JS est d\u00e9marr\u00e9." + }, "user": { "data": { "url": "URL" diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index fc76b309a34..abe0ab066fb 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -6,6 +6,7 @@ "addon_install_failed": "Impossibile installare il componente aggiuntivo Z-Wave JS.", "addon_missing_discovery_info": "Informazioni sul rilevamento del componente aggiuntivo Z-Wave JS mancanti.", "addon_set_config_failed": "Impossibile impostare la configurazione di Z-Wave JS.", + "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS.", "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "cannot_connect": "Impossibile connettersi" @@ -17,9 +18,17 @@ "unknown": "Errore imprevisto" }, "progress": { - "install_addon": "Attendi il termine dell'installazione del componente aggiuntivo Z-Wave JS. Questa operazione pu\u00f2 richiedere diversi minuti." + "install_addon": "Attendi il termine dell'installazione del componente aggiuntivo Z-Wave JS. Questa operazione pu\u00f2 richiedere diversi minuti.", + "start_addon": "Attendi il completamento dell'avvio del componente aggiuntivo Z-Wave JS. L'operazione potrebbe richiedere alcuni secondi." }, "step": { + "configure_addon": { + "data": { + "network_key": "Chiave di rete", + "usb_path": "Percorso del dispositivo USB" + }, + "title": "Accedi alla configurazione del componente aggiuntivo Z-Wave JS" + }, "hassio_confirm": { "title": "Configura l'integrazione di Z-Wave JS con il componente aggiuntivo Z-Wave JS" }, @@ -39,11 +48,7 @@ "title": "Seleziona il metodo di connessione" }, "start_addon": { - "data": { - "network_key": "Chiave di rete", - "usb_path": "Percorso del dispositivo USB" - }, - "title": "Accedi alla configurazione del componente aggiuntivo Z-Wave JS" + "title": "Il componente aggiuntivo Z-Wave JS si sta avviando." }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/ko.json b/homeassistant/components/zwave_js/translations/ko.json new file mode 100644 index 00000000000..9c86a064151 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/ko.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "configure_addon": { + "data": { + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + }, + "manual": { + "data": { + "url": "URL \uc8fc\uc18c" + } + }, + "user": { + "data": { + "url": "URL \uc8fc\uc18c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json new file mode 100644 index 00000000000..c15cfd26f31 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "addon_get_discovery_info_failed": "Ophalen van ontdekkingsinformatie voor Z-Wave JS-add-on is mislukt.", + "addon_info_failed": "Ophalen van Z-Wave JS add-on-info is mislukt.", + "addon_install_failed": "Kan de Z-Wave JS add-on niet installeren.", + "addon_missing_discovery_info": "De Z-Wave JS addon mist ontdekkings informatie", + "addon_set_config_failed": "Instellen van de Z-Wave JS configuratie is mislukt.", + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "cannot_connect": "Kan geen verbinding maken" + }, + "error": { + "addon_start_failed": "Het is niet gelukt om de Z-Wave JS add-on te starten. Controleer de configuratie.", + "cannot_connect": "Kan geen verbinding maken", + "invalid_ws_url": "Ongeldige websocket URL", + "unknown": "Onverwachte fout" + }, + "progress": { + "install_addon": "Een ogenblik geduld terwijl de installatie van de Z-Wave JS add-on is voltooid. Dit kan enkele minuten duren." + }, + "step": { + "configure_addon": { + "data": { + "network_key": "Netwerksleutel", + "usb_path": "USB-apparaatpad" + }, + "title": "Voer de Z-Wave JS add-on configuratie in" + }, + "hassio_confirm": { + "title": "Z-Wave JS integratie instellen met de Z-Wave JS add-on" + }, + "install_addon": { + "title": "De Z-Wave JS add-on installatie is gestart" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Gebruik de Z-Wave JS Supervisor add-on" + }, + "description": "Wilt u de Z-Wave JS Supervisor add-on gebruiken?", + "title": "Selecteer verbindingsmethode" + }, + "user": { + "data": { + "url": "URL" + } + } + } + }, + "title": "Z-Wave JS" +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index e16425b59ec..f893d2d7684 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -6,6 +6,7 @@ "addon_install_failed": "Kunne ikke installere Z-Wave JS-tillegg", "addon_missing_discovery_info": "Manglende oppdagelsesinformasjon for Z-Wave JS-tillegg", "addon_set_config_failed": "Kunne ikke angi Z-Wave JS-konfigurasjon", + "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegget.", "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes" @@ -17,9 +18,17 @@ "unknown": "Uventet feil" }, "progress": { - "install_addon": "Vent mens installasjonen av Z-Wave JS-tillegg er ferdig. Dette kan ta flere minutter." + "install_addon": "Vent mens installasjonen av Z-Wave JS-tillegg er ferdig. Dette kan ta flere minutter.", + "start_addon": "Vent mens Z-Wave JS-tillegget er ferdig startet. Dette kan ta noen sekunder." }, "step": { + "configure_addon": { + "data": { + "network_key": "Nettverksn\u00f8kkel", + "usb_path": "USB enhetsbane" + }, + "title": "Angi konfigurasjon for Z-Wave JS-tillegg" + }, "hassio_confirm": { "title": "Sett opp Z-Wave JS-integrasjon med Z-Wave JS-tillegg" }, @@ -39,11 +48,7 @@ "title": "Velg tilkoblingsmetode" }, "start_addon": { - "data": { - "network_key": "Nettverksn\u00f8kkel", - "usb_path": "USB enhetsbane" - }, - "title": "Angi konfigurasjon for Z-Wave JS-tillegg" + "title": "Z-Wave JS-tillegget starter" }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index 47e263c6101..2bfd994132b 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -6,6 +6,7 @@ "addon_install_failed": "Nie uda\u0142o si\u0119 zainstalowa\u0107 dodatku Z-Wave JS", "addon_missing_discovery_info": "Brak informacji wykrywania dodatku Z-Wave JS", "addon_set_config_failed": "Nie uda\u0142o si\u0119 skonfigurowa\u0107 Z-Wave JS", + "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS.", "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" @@ -17,9 +18,17 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "progress": { - "install_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 instalacja dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 kilka minut." + "install_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 instalacja dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 kilka minut.", + "start_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 uruchamianie dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 chwil\u0119." }, "step": { + "configure_addon": { + "data": { + "network_key": "Klucz sieci", + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" + }, "hassio_confirm": { "title": "Skonfiguruj integracj\u0119 Z-Wave JS z dodatkiem Z-Wave JS" }, @@ -39,11 +48,7 @@ "title": "Wybierz metod\u0119 po\u0142\u0105czenia" }, "start_addon": { - "data": { - "network_key": "Klucz sieci", - "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" - }, - "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" + "title": "Dodatek Z-Wave JS uruchamia si\u0119." }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 2d9609e9d00..1a65ce3ea71 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -6,6 +6,7 @@ "addon_install_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS.", "addon_missing_discovery_info": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438 Z-Wave JS.", "addon_set_config_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e Z-Wave JS.", + "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS.", "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." @@ -17,9 +18,17 @@ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "progress": { - "install_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0438\u043d\u0443\u0442." + "install_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0438\u043d\u0443\u0442.", + "start_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0437\u0430\u043f\u0443\u0441\u043a \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0441\u0435\u043a\u0443\u043d\u0434." }, "step": { + "configure_addon": { + "data": { + "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" + }, "hassio_confirm": { "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Z-Wave JS (\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Home Assistant Z-Wave JS)" }, @@ -39,11 +48,7 @@ "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" }, "start_addon": { - "data": { - "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", - "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" - }, - "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" + "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS \u0437\u0430\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f" }, "user": { "data": { diff --git a/homeassistant/components/zwave_js/translations/tr.json b/homeassistant/components/zwave_js/translations/tr.json index 2faa8ba4307..04ddcc5252c 100644 --- a/homeassistant/components/zwave_js/translations/tr.json +++ b/homeassistant/components/zwave_js/translations/tr.json @@ -20,6 +20,13 @@ "install_addon": "L\u00fctfen Z-Wave JS eklenti kurulumu bitene kadar bekleyin. Bu birka\u00e7 dakika s\u00fcrebilir." }, "step": { + "configure_addon": { + "data": { + "network_key": "A\u011f Anahtar\u0131", + "usb_path": "USB Ayg\u0131t Yolu" + }, + "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" + }, "hassio_confirm": { "title": "Z-Wave JS eklentisiyle Z-Wave JS entegrasyonunu ayarlay\u0131n" }, @@ -38,13 +45,6 @@ "description": "Z-Wave JS Supervisor eklentisini kullanmak istiyor musunuz?", "title": "Ba\u011flant\u0131 y\u00f6ntemini se\u00e7in" }, - "start_addon": { - "data": { - "network_key": "A\u011f Anahtar\u0131", - "usb_path": "USB Ayg\u0131t Yolu" - }, - "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" - }, "user": { "data": { "url": "URL" diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 1cbde8f886b..f1495b1aeda 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -6,6 +6,7 @@ "addon_install_failed": "Z-Wave JS add-on \u5b89\u88dd\u5931\u6557\u3002", "addon_missing_discovery_info": "\u7f3a\u5c11 Z-Wave JS add-on \u63a2\u7d22\u8cc7\u8a0a\u3002", "addon_set_config_failed": "Z-Wave JS add-on \u8a2d\u5b9a\u5931\u6557\u3002", + "addon_start_failed": "Z-Wave JS add-on \u555f\u59cb\u5931\u6557\u3002", "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "cannot_connect": "\u9023\u7dda\u5931\u6557" @@ -17,9 +18,17 @@ "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "progress": { - "install_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS add-on \u5b89\u88dd\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002" + "install_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS add-on \u5b89\u88dd\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002", + "start_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS add-on \u555f\u59cb\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002" }, "step": { + "configure_addon": { + "data": { + "network_key": "\u7db2\u8def\u5bc6\u9470", + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u8a2d\u5b9a" + }, "hassio_confirm": { "title": "\u4ee5 Z-Wave JS add-on \u8a2d\u5b9a Z-Wave JS \u6574\u5408" }, @@ -39,11 +48,7 @@ "title": "\u9078\u64c7\u9023\u7dda\u985e\u578b" }, "start_addon": { - "data": { - "network_key": "\u7db2\u8def\u5bc6\u9470", - "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" - }, - "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u8a2d\u5b9a" + "title": "Z-Wave JS add-on \u555f\u59cb\u4e2d\u3002" }, "user": { "data": { diff --git a/homeassistant/config.py b/homeassistant/config.py index 2da9b0331c9..90df365c349 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,6 +1,5 @@ """Module to help with parsing and generating configuration files.""" from collections import OrderedDict -from distutils.version import LooseVersion # pylint: disable=import-error import logging import os import re @@ -8,6 +7,7 @@ import shutil from types import ModuleType from typing import Any, Callable, Dict, Optional, Sequence, Set, Tuple, Union +from awesomeversion import AwesomeVersion import voluptuous as vol from voluptuous.humanize import humanize_error @@ -51,6 +51,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, extract_domain_configs import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_values import EntityValues +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, IntegrationNotFound from homeassistant.requirements import ( RequirementsNotFound, @@ -75,6 +76,13 @@ AUTOMATION_CONFIG_PATH = "automations.yaml" SCRIPT_CONFIG_PATH = "scripts.yaml" SCENE_CONFIG_PATH = "scenes.yaml" +LOAD_EXCEPTIONS = (ImportError, FileNotFoundError) +INTEGRATION_LOAD_EXCEPTIONS = ( + IntegrationNotFound, + RequirementsNotFound, + *LOAD_EXCEPTIONS, +) + DEFAULT_CONFIG = f""" # Configure a default setup of Home Assistant (frontend, api, etc) default_config: @@ -363,15 +371,15 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: "Upgrading configuration directory from %s to %s", conf_version, __version__ ) - version_obj = LooseVersion(conf_version) + version_obj = AwesomeVersion(conf_version) - if version_obj < LooseVersion("0.50"): + if version_obj < AwesomeVersion("0.50"): # 0.50 introduced persistent deps dir. lib_path = hass.config.path("deps") if os.path.isdir(lib_path): shutil.rmtree(lib_path) - if version_obj < LooseVersion("0.92"): + if version_obj < AwesomeVersion("0.92"): # 0.92 moved google/tts.py to google_translate/tts.py config_path = hass.config.path(YAML_CONFIG_FILE) @@ -387,7 +395,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: except OSError: _LOGGER.exception("Migrating to google_translate tts failed") - if version_obj < LooseVersion("0.94") and is_docker_env(): + if version_obj < AwesomeVersion("0.94") and is_docker_env(): # In 0.94 we no longer install packages inside the deps folder when # running inside a Docker container. lib_path = hass.config.path("deps") @@ -688,7 +696,7 @@ async def merge_packages_config( hass, domain ) component = integration.get_component() - except (IntegrationNotFound, RequirementsNotFound, ImportError) as ex: + except INTEGRATION_LOAD_EXCEPTIONS as ex: _log_pkg_error(pack_name, comp_name, config, str(ex)) continue @@ -734,8 +742,8 @@ async def merge_packages_config( async def async_process_component_config( - hass: HomeAssistant, config: Dict, integration: Integration -) -> Optional[Dict]: + hass: HomeAssistant, config: ConfigType, integration: Integration +) -> Optional[ConfigType]: """Check component configuration and return processed configuration. Returns None on error. @@ -745,7 +753,7 @@ async def async_process_component_config( domain = integration.domain try: component = integration.get_component() - except ImportError as ex: + except LOAD_EXCEPTIONS as ex: _LOGGER.error("Unable to import %s: %s", domain, ex) return None @@ -824,7 +832,7 @@ async def async_process_component_config( try: platform = p_integration.get_platform(domain) - except ImportError: + except LOAD_EXCEPTIONS: _LOGGER.exception("Platform error: %s", domain) continue diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index abc6b2f46af..12a795d0a51 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1,4 +1,6 @@ """Manage config entries in Home Assistant.""" +from __future__ import annotations + import asyncio import functools import logging @@ -11,7 +13,7 @@ import attr from homeassistant import data_entry_flow, loader from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.event import Event from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_process_deps_reqs, async_setup_component @@ -90,6 +92,8 @@ CONN_CLASS_LOCAL_POLL = "local_poll" CONN_CLASS_ASSUMED = "assumed" CONN_CLASS_UNKNOWN = "unknown" +DISABLED_USER = "user" + RELOAD_AFTER_UPDATE_DELAY = 30 @@ -124,6 +128,7 @@ class ConfigEntry: "source", "connection_class", "state", + "disabled_by", "_setup_lock", "update_listeners", "_async_cancel_retry_setup", @@ -142,6 +147,7 @@ class ConfigEntry: unique_id: Optional[str] = None, entry_id: Optional[str] = None, state: str = ENTRY_STATE_NOT_LOADED, + disabled_by: Optional[str] = None, ) -> None: """Initialize a config entry.""" # Unique id of the config entry @@ -177,6 +183,9 @@ class ConfigEntry: # Unique ID of this entry. self.unique_id = unique_id + # Config entry is disabled + self.disabled_by = disabled_by + # Supports unload self.supports_unload = False @@ -196,7 +205,7 @@ class ConfigEntry: tries: int = 0, ) -> None: """Set up an entry.""" - if self.source == SOURCE_IGNORE: + if self.source == SOURCE_IGNORE or self.disabled_by: return if integration is None: @@ -247,12 +256,19 @@ class ConfigEntry: self.state = ENTRY_STATE_SETUP_RETRY wait_time = 2 ** min(tries, 4) * 5 tries += 1 - _LOGGER.warning( - "Config entry '%s' for %s integration not ready yet. Retrying in %d seconds", - self.title, - self.domain, - wait_time, - ) + if tries == 1: + _LOGGER.warning( + "Config entry '%s' for %s integration not ready yet. Retrying in background", + self.title, + self.domain, + ) + else: + _LOGGER.debug( + "Config entry '%s' for %s integration not ready yet. Retrying in %d seconds", + self.title, + self.domain, + wait_time, + ) async def setup_again(now: Any) -> None: """Run setup again.""" @@ -439,6 +455,7 @@ class ConfigEntry: "source": self.source, "connection_class": self.connection_class, "unique_id": self.unique_id, + "disabled_by": self.disabled_by, } @@ -526,7 +543,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): async def async_create_flow( self, handler_key: Any, *, context: Optional[Dict] = None, data: Any = None - ) -> "ConfigFlow": + ) -> ConfigFlow: """Create a flow for specified handler. Handler key is the domain of the component that we want to set up. @@ -709,6 +726,8 @@ class ConfigEntries: system_options=entry.get("system_options", {}), # New in 0.104 unique_id=entry.get("unique_id"), + # New in 2021.3 + disabled_by=entry.get("disabled_by"), ) for entry in config["entries"] ] @@ -757,13 +776,54 @@ class ConfigEntries: If an entry was not loaded, will just load. """ + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + unload_result = await self.async_unload(entry_id) - if not unload_result: + if not unload_result or entry.disabled_by: return unload_result return await self.async_setup(entry_id) + async def async_set_disabled_by( + self, entry_id: str, disabled_by: Optional[str] + ) -> bool: + """Disable an entry. + + If disabled_by is changed, the config entry will be reloaded. + """ + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + + if entry.disabled_by == disabled_by: + return True + + entry.disabled_by = disabled_by + self._async_schedule_save() + + dev_reg = device_registry.async_get(self.hass) + ent_reg = entity_registry.async_get(self.hass) + + if not entry.disabled_by: + # The config entry will no longer be disabled, enable devices and entities + device_registry.async_config_entry_disabled_by_changed(dev_reg, entry) + entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry) + + # Load or unload the config entry + reload_result = await self.async_reload(entry_id) + + if entry.disabled_by: + # The config entry has been disabled, disable devices and entities + device_registry.async_config_entry_disabled_by_changed(dev_reg, entry) + entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry) + + return reload_result + @callback def async_update_entry( self, @@ -890,7 +950,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> "OptionsFlow": + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" raise data_entry_flow.UnknownHandler @@ -901,11 +961,10 @@ class ConfigFlow(data_entry_flow.FlowHandler): reload_on_update: bool = True, ) -> None: """Abort if the unique ID is already configured.""" - assert self.hass if self.unique_id is None: return - for entry in self._async_current_entries(): + for entry in self._async_current_entries(include_ignore=True): if entry.unique_id == self.unique_id: if updates is not None: changed = self.hass.config_entries.async_update_entry( @@ -943,28 +1002,33 @@ class ConfigFlow(data_entry_flow.FlowHandler): self.context["unique_id"] = unique_id # pylint: disable=no-member # Abort discoveries done using the default discovery unique id - assert self.hass is not None if unique_id != DEFAULT_DISCOVERY_UNIQUE_ID: for progress in self._async_in_progress(): if progress["context"].get("unique_id") == DEFAULT_DISCOVERY_UNIQUE_ID: self.hass.config_entries.flow.async_abort(progress["flow_id"]) - for entry in self._async_current_entries(): + for entry in self._async_current_entries(include_ignore=True): if entry.unique_id == unique_id: return entry return None @callback - def _async_current_entries(self) -> List[ConfigEntry]: - """Return current entries.""" - assert self.hass is not None - return self.hass.config_entries.async_entries(self.handler) + def _async_current_entries(self, include_ignore: bool = False) -> List[ConfigEntry]: + """Return current entries. + + If the flow is user initiated, filter out ignored entries unless include_ignore is True. + """ + config_entries = self.hass.config_entries.async_entries(self.handler) + + if include_ignore or self.source != SOURCE_USER: + return config_entries + + return [entry for entry in config_entries if entry.source != SOURCE_IGNORE] @callback def _async_current_ids(self, include_ignore: bool = True) -> Set[Optional[str]]: """Return current unique IDs.""" - assert self.hass is not None return { entry.unique_id for entry in self.hass.config_entries.async_entries(self.handler) @@ -974,7 +1038,6 @@ class ConfigFlow(data_entry_flow.FlowHandler): @callback def _async_in_progress(self) -> List[Dict]: """Return other in progress flows for current domain.""" - assert self.hass is not None return [ flw for flw in self.hass.config_entries.flow.async_progress() @@ -1017,7 +1080,6 @@ class ConfigFlow(data_entry_flow.FlowHandler): self._abort_if_unique_id_configured() # Abort if any other flow for this handler is already in progress - assert self.hass is not None if self._async_in_progress(): raise data_entry_flow.AbortFlow("already_in_progress") @@ -1033,8 +1095,6 @@ class ConfigFlow(data_entry_flow.FlowHandler): self, *, reason: str, description_placeholders: Optional[Dict] = None ) -> Dict[str, Any]: """Abort the config flow.""" - assert self.hass - # Remove reauth notification if no reauth flows are in progress if self.source == SOURCE_REAUTH and not any( ent["context"]["source"] == SOURCE_REAUTH @@ -1066,7 +1126,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): *, context: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, - ) -> "OptionsFlow": + ) -> OptionsFlow: """Create an options flow for a config entry. Entry_id and flow.handler is the same thing to map entry with flow. @@ -1137,17 +1197,13 @@ class EntityRegistryDisabledHandler: def async_setup(self) -> None: """Set up the disable handler.""" self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entry_updated + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entry_updated, + event_filter=_handle_entry_updated_filter, ) async def _handle_entry_updated(self, event: Event) -> None: """Handle entity registry entry update.""" - if ( - event.data["action"] != "update" - or "disabled_by" not in event.data["changes"] - ): - return - if self.registry is None: self.registry = await entity_registry.async_get_registry(self.hass) @@ -1201,6 +1257,22 @@ class EntityRegistryDisabledHandler: ) +@callback +def _handle_entry_updated_filter(event: Event) -> bool: + """Handle entity registry entry update filter. + + Only handle changes to "disabled_by". + If "disabled_by" was DISABLED_CONFIG_ENTRY, reload is not needed. + """ + if ( + event.data["action"] != "update" + or "disabled_by" not in event.data["changes"] + or event.data["changes"]["disabled_by"] == entity_registry.DISABLED_CONFIG_ENTRY + ): + return False + return True + + async def support_entry_unload(hass: HomeAssistant, domain: str) -> bool: """Test if a domain supports entry unloading.""" integration = await loader.async_get_integration(hass, domain) diff --git a/homeassistant/const.py b/homeassistant/const.py index 12772b1d2d1..ec2ab3bff0c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 2021 -MINOR_VERSION = 2 -PATCH_VERSION = "3" +MINOR_VERSION = 3 +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 8, 0) diff --git a/homeassistant/core.py b/homeassistant/core.py index dfdb77a44a8..b62dd1ee7d5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -8,7 +8,6 @@ import asyncio import datetime import enum import functools -from ipaddress import ip_address import logging import os import pathlib @@ -29,6 +28,7 @@ from typing import ( Mapping, Optional, Set, + Tuple, TypeVar, Union, cast, @@ -70,7 +70,7 @@ from homeassistant.exceptions import ( ServiceNotFound, Unauthorized, ) -from homeassistant.util import location, network +from homeassistant.util import location from homeassistant.util.async_ import ( fire_coroutine_threadsafe, run_callback_threadsafe, @@ -209,7 +209,7 @@ class CoreState(enum.Enum): def __str__(self) -> str: # pylint: disable=invalid-str-returned """Return the event.""" - return self.value # type: ignore + return self.value class HomeAssistant: @@ -596,7 +596,7 @@ class EventOrigin(enum.Enum): def __str__(self) -> str: # pylint: disable=invalid-str-returned """Return the event.""" - return self.value # type: ignore + return self.value class Event: @@ -662,7 +662,7 @@ class EventBus: def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners: Dict[str, List[HassJob]] = {} + self._listeners: Dict[str, List[Tuple[HassJob, Optional[Callable]]]] = {} self._hass = hass @callback @@ -718,7 +718,14 @@ class EventBus: if not listeners: return - for job in listeners: + for job, event_filter in listeners: + if event_filter is not None: + try: + if not event_filter(event): + continue + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in event filter") + continue self._hass.async_add_hass_job(job, event) def listen(self, event_type: str, listener: Callable) -> CALLBACK_TYPE: @@ -738,23 +745,38 @@ class EventBus: return remove_listener @callback - def async_listen(self, event_type: str, listener: Callable) -> CALLBACK_TYPE: + def async_listen( + self, + event_type: str, + listener: Callable, + event_filter: Optional[Callable] = None, + ) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. To listen to all events specify the constant ``MATCH_ALL`` as event_type. + An optional event_filter, which must be a callable decorated with + @callback that returns a boolean value, determines if the + listener callable should run. + This method must be run in the event loop. """ - return self._async_listen_job(event_type, HassJob(listener)) + if event_filter is not None and not is_callback(event_filter): + raise HomeAssistantError(f"Event filter {event_filter} is not a callback") + return self._async_listen_filterable_job( + event_type, (HassJob(listener), event_filter) + ) @callback - def _async_listen_job(self, event_type: str, hassjob: HassJob) -> CALLBACK_TYPE: - self._listeners.setdefault(event_type, []).append(hassjob) + def _async_listen_filterable_job( + self, event_type: str, filterable_job: Tuple[HassJob, Optional[Callable]] + ) -> CALLBACK_TYPE: + self._listeners.setdefault(event_type, []).append(filterable_job) def remove_listener() -> None: """Remove the listener.""" - self._async_remove_listener(event_type, hassjob) + self._async_remove_listener(event_type, filterable_job) return remove_listener @@ -787,12 +809,12 @@ class EventBus: This method must be run in the event loop. """ - job: Optional[HassJob] = None + filterable_job: Optional[Tuple[HassJob, Optional[Callable]]] = None @callback def _onetime_listener(event: Event) -> None: """Remove listener from event bus and then fire listener.""" - nonlocal job + nonlocal filterable_job if hasattr(_onetime_listener, "run"): return # Set variable so that we will never run twice. @@ -801,22 +823,24 @@ class EventBus: # multiple times as well. # This will make sure the second time it does nothing. setattr(_onetime_listener, "run", True) - assert job is not None - self._async_remove_listener(event_type, job) + assert filterable_job is not None + self._async_remove_listener(event_type, filterable_job) self._hass.async_run_job(listener, event) - job = HassJob(_onetime_listener) + filterable_job = (HassJob(_onetime_listener), None) - return self._async_listen_job(event_type, job) + return self._async_listen_filterable_job(event_type, filterable_job) @callback - def _async_remove_listener(self, event_type: str, hassjob: HassJob) -> None: + def _async_remove_listener( + self, event_type: str, filterable_job: Tuple[HassJob, Optional[Callable]] + ) -> None: """Remove a listener of a specific event_type. This method must be run in the event loop. """ try: - self._listeners[event_type].remove(hassjob) + self._listeners[event_type].remove(filterable_job) # delete event_type list if empty if not self._listeners[event_type]: @@ -824,7 +848,9 @@ class EventBus: except (KeyError, ValueError): # KeyError is key event_type listener did not exist # ValueError if listener did not exist within event_type - _LOGGER.exception("Unable to remove unknown job listener %s", hassjob) + _LOGGER.exception( + "Unable to remove unknown job listener %s", filterable_job + ) class State: @@ -1358,6 +1384,7 @@ class ServiceRegistry: blocking: bool = False, context: Optional[Context] = None, limit: Optional[float] = SERVICE_CALL_LIMIT, + target: Optional[Dict] = None, ) -> Optional[bool]: """ Call a service. @@ -1365,7 +1392,9 @@ class ServiceRegistry: See description of async_call for details. """ return asyncio.run_coroutine_threadsafe( - self.async_call(domain, service, service_data, blocking, context, limit), + self.async_call( + domain, service, service_data, blocking, context, limit, target + ), self._hass.loop, ).result() @@ -1377,6 +1406,7 @@ class ServiceRegistry: blocking: bool = False, context: Optional[Context] = None, limit: Optional[float] = SERVICE_CALL_LIMIT, + target: Optional[Dict] = None, ) -> Optional[bool]: """ Call a service. @@ -1404,6 +1434,9 @@ class ServiceRegistry: except KeyError: raise ServiceNotFound(domain, service) from None + if target: + service_data.update(target) + if handler.schema: try: processed_data = handler.schema(service_data) @@ -1680,39 +1713,7 @@ class Config: ) data = await store.async_load() - async def migrate_base_url(_: Event) -> None: - """Migrate base_url to internal_url/external_url.""" - if self.hass.config.api is None: - return - - base_url = yarl.URL(self.hass.config.api.deprecated_base_url) - - # Check if this is an internal URL - if str(base_url.host).endswith(".local") or ( - network.is_ip_address(str(base_url.host)) - and network.is_private(ip_address(base_url.host)) - ): - await self.async_update( - internal_url=network.normalize_url(str(base_url)) - ) - return - - # External, ensure this is not a loopback address - if not ( - network.is_ip_address(str(base_url.host)) - and network.is_loopback(ip_address(base_url.host)) - ): - await self.async_update( - external_url=network.normalize_url(str(base_url)) - ) - if data: - # Try to migrate base_url to internal_url/external_url - if "external_url" not in data: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, migrate_base_url - ) - self._update( source=SOURCE_STORAGE, latitude=data.get("latitude"), diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index c5b67ff16e8..e8235c9a23c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -1,7 +1,10 @@ """Classes to help gather user submissions.""" +from __future__ import annotations + import abc import asyncio -from typing import Any, Dict, List, Optional, cast +from types import MappingProxyType +from typing import Any, Dict, List, Optional import uuid import voluptuous as vol @@ -75,7 +78,7 @@ class FlowManager(abc.ABC): *, context: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, - ) -> "FlowHandler": + ) -> FlowHandler: """Create a flow for specified handler. Handler key is the domain of the component that we want to set up. @@ -262,11 +265,14 @@ class FlowHandler: """Handle the configuration flow of a component.""" # Set by flow manager - flow_id: str = None # type: ignore - hass: Optional[HomeAssistant] = None - handler: Optional[str] = None cur_step: Optional[Dict[str, str]] = None - context: Dict + # Ignore types, pylint workaround: https://github.com/PyCQA/pylint/issues/3167 + flow_id: str = None # type: ignore + hass: HomeAssistant = None # type: ignore + handler: str = None # type: ignore + # Pylint workaround: https://github.com/PyCQA/pylint/issues/3167 + # Ensure the attribute has a subscriptable, but immutable, default value. + context: Dict = MappingProxyType({}) # type: ignore # Set by _async_create_flow callback init_step = "init" @@ -337,7 +343,7 @@ class FlowHandler: ) -> Dict[str, Any]: """Abort the config flow.""" return _create_abort_data( - self.flow_id, cast(str, self.handler), reason, description_placeholders + self.flow_id, self.handler, reason, description_placeholders ) @callback diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index e37f68a07bf..84ba2cfa348 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,5 +1,7 @@ """The exceptions used by Home Assistant.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Generator, Optional, Sequence + +import attr if TYPE_CHECKING: from .core import Context # noqa: F401 pylint: disable=unused-import @@ -25,6 +27,75 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") +@attr.s +class ConditionError(HomeAssistantError): + """Error during condition evaluation.""" + + # The type of the failed condition, such as 'and' or 'numeric_state' + type: str = attr.ib() + + @staticmethod + def _indent(indent: int, message: str) -> str: + """Return indentation.""" + return " " * indent + message + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + raise NotImplementedError() + + def __str__(self) -> str: + """Return string representation.""" + return "\n".join(list(self.output(indent=0))) + + +@attr.s +class ConditionErrorMessage(ConditionError): + """Condition error message.""" + + # A message describing this error + message: str = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + yield self._indent(indent, f"In '{self.type}' condition: {self.message}") + + +@attr.s +class ConditionErrorIndex(ConditionError): + """Condition error with index.""" + + # The zero-based index of the failed condition, for conditions with multiple parts + index: int = attr.ib() + # The total number of parts in this condition, including non-failed parts + total: int = attr.ib() + # The error that this error wraps + error: ConditionError = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + if self.total > 1: + yield self._indent( + indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + ) + else: + yield self._indent(indent, f"In '{self.type}':") + + yield from self.error.output(indent + 1) + + +@attr.s +class ConditionErrorContainer(ConditionError): + """Condition error with subconditions.""" + + # List of ConditionErrors that this error wraps + errors: Sequence[ConditionError] = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + for item in self.errors: + yield from item.output(indent) + + class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 77a1dc91dd7..c3d629ebe29 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -11,6 +11,7 @@ FLOWS = [ "acmeda", "adguard", "advantage_air", + "aemet", "agent_dvr", "airly", "airnow", @@ -21,6 +22,7 @@ FLOWS = [ "ambient_station", "apple_tv", "arcam_fmj", + "asuswrt", "atag", "august", "aurora", @@ -38,6 +40,7 @@ FLOWS = [ "canary", "cast", "cert_expiry", + "climacell", "cloudflare", "control4", "coolmaster", @@ -61,6 +64,7 @@ FLOWS = [ "enocean", "epson", "esphome", + "faa_delays", "fireservicerota", "flick_electric", "flo", @@ -84,6 +88,7 @@ FLOWS = [ "gree", "griddy", "guardian", + "habitica", "hangouts", "harmony", "heos", @@ -111,17 +116,23 @@ FLOWS = [ "isy994", "izone", "juicenet", + "keenetic_ndms2", + "kmtronic", "kodi", "konnected", "kulersky", "life360", "lifx", + "litejet", + "litterrobot", "local_ip", "locative", "logi_circle", "luftdaten", "lutron_caseta", + "lyric", "mailgun", + "mazda", "melcloud", "met", "meteo_france", @@ -133,7 +144,9 @@ FLOWS = [ "monoprice", "motion_blinds", "mqtt", + "mullvad", "myq", + "mysensors", "neato", "nest", "netatmo", @@ -141,6 +154,7 @@ FLOWS = [ "nightscout", "notion", "nuheat", + "nuki", "nut", "nws", "nzbget", @@ -155,6 +169,7 @@ FLOWS = [ "owntracks", "ozw", "panasonic_viera", + "philips_js", "pi_hole", "plaato", "plex", @@ -173,6 +188,7 @@ FLOWS = [ "rfxtrx", "ring", "risco", + "rituals_perfume_genie", "roku", "roomba", "roon", @@ -189,6 +205,7 @@ FLOWS = [ "smart_meter_texas", "smarthab", "smartthings", + "smarttub", "smhi", "sms", "solaredge", @@ -205,6 +222,7 @@ FLOWS = [ "squeezebox", "srp_energy", "starline", + "subaru", "syncthru", "synology_dsm", "tado", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0b6f5166f88..31ee42bc48c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -41,6 +41,21 @@ DHCP = [ "hostname": "flume-gw-*", "macaddress": "B4E62D*" }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "48A2E6" + }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "B82CA0" + }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "00D02D" + }, { "domain": "nest", "macaddress": "18B430*" @@ -55,6 +70,10 @@ DHCP = [ "hostname": "nuheat", "macaddress": "002338*" }, + { + "domain": "nuki", + "hostname": "nuki_bridge_*" + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 1a919996f86..164207a8b2a 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -1,16 +1,18 @@ """Provide a way to connect devices to one physical location.""" -from asyncio import Event, gather from collections import OrderedDict from typing import Container, Dict, Iterable, List, MutableMapping, Optional, cast import attr from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.loader import bind_hass from homeassistant.util import slugify from .typing import HomeAssistantType +# mypy: disallow-any-generics + DATA_REGISTRY = "area_registry" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" @@ -23,9 +25,10 @@ class AreaEntry: """Area Registry Entry.""" name: str = attr.ib() + normalized_name: str = attr.ib() id: Optional[str] = attr.ib(default=None) - def generate_id(self, existing_ids: Container) -> None: + def generate_id(self, existing_ids: Container[str]) -> None: """Initialize ID.""" suggestion = suggestion_base = slugify(self.name) tries = 1 @@ -43,43 +46,64 @@ class AreaRegistry: self.hass = hass self.areas: MutableMapping[str, AreaEntry] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._normalized_name_area_idx: Dict[str, str] = {} @callback def async_get_area(self, area_id: str) -> Optional[AreaEntry]: - """Get all areas.""" + """Get area by id.""" return self.areas.get(area_id) + @callback + def async_get_area_by_name(self, name: str) -> Optional[AreaEntry]: + """Get area by name.""" + normalized_name = normalize_area_name(name) + if normalized_name not in self._normalized_name_area_idx: + return None + return self.areas[self._normalized_name_area_idx[normalized_name]] + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" return self.areas.values() + @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: + return area + return self.async_create(name) + @callback def async_create(self, name: str) -> AreaEntry: """Create a new area.""" - if self._async_is_registered(name): - raise ValueError("Name is already in use") + normalized_name = normalize_area_name(name) - area = AreaEntry(name=name) + if self.async_get_area_by_name(name): + raise ValueError(f"The name {name} ({normalized_name}) is already in use") + + area = AreaEntry(name=name, normalized_name=normalized_name) area.generate_id(self.areas) assert area.id is not None self.areas[area.id] = area + self._normalized_name_area_idx[normalized_name] = area.id self.async_schedule_save() self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "create", "area_id": area.id} ) return area - async def async_delete(self, area_id: str) -> None: + @callback + def async_delete(self, area_id: str) -> None: """Delete area.""" - device_registry, entity_registry = await gather( - self.hass.helpers.device_registry.async_get_registry(), - self.hass.helpers.entity_registry.async_get_registry(), - ) + area = self.areas[area_id] + device_registry = dr.async_get(self.hass) + entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) entity_registry.async_clear_area_id(area_id) del self.areas[area_id] + del self._normalized_name_area_idx[area.normalized_name] self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "remove", "area_id": area_id} @@ -106,23 +130,25 @@ class AreaRegistry: if name == old.name: return old - if self._async_is_registered(name): - raise ValueError("Name is already in use") + normalized_name = normalize_area_name(name) + + if normalized_name != old.normalized_name: + if self.async_get_area_by_name(name): + raise ValueError( + f"The name {name} ({normalized_name}) is already in use" + ) changes["name"] = name + changes["normalized_name"] = normalized_name new = self.areas[area_id] = attr.evolve(old, **changes) + self._normalized_name_area_idx[ + normalized_name + ] = self._normalized_name_area_idx.pop(old.normalized_name) + self.async_schedule_save() return new - @callback - def _async_is_registered(self, name: str) -> Optional[AreaEntry]: - """Check if a name is currently registered.""" - for area in self.areas.values(): - if name == area.name: - return area - return None - async def async_load(self) -> None: """Load the area registry.""" data = await self._store.async_load() @@ -131,7 +157,11 @@ class AreaRegistry: if data is not None: for area in data["areas"]: - areas[area["id"]] = AreaEntry(name=area["name"], id=area["id"]) + normalized_name = normalize_area_name(area["name"]) + areas[area["id"]] = AreaEntry( + name=area["name"], id=area["id"], normalized_name=normalized_name + ) + self._normalized_name_area_idx[normalized_name] = area["id"] self.areas = areas @@ -146,30 +176,38 @@ class AreaRegistry: data = {} data["areas"] = [ - {"name": entry.name, "id": entry.id} for entry in self.areas.values() + { + "name": entry.name, + "id": entry.id, + } + for entry in self.areas.values() ] return data +@callback +def async_get(hass: HomeAssistantType) -> AreaRegistry: + """Get area registry.""" + return cast(AreaRegistry, hass.data[DATA_REGISTRY]) + + +async def async_load(hass: HomeAssistantType) -> None: + """Load area registry.""" + assert DATA_REGISTRY not in hass.data + hass.data[DATA_REGISTRY] = AreaRegistry(hass) + await hass.data[DATA_REGISTRY].async_load() + + @bind_hass async def async_get_registry(hass: HomeAssistantType) -> AreaRegistry: - """Return area registry instance.""" - reg_or_evt = hass.data.get(DATA_REGISTRY) + """Get area registry. - if not reg_or_evt: - evt = hass.data[DATA_REGISTRY] = Event() + This is deprecated and will be removed in the future. Use async_get instead. + """ + return async_get(hass) - reg = AreaRegistry(hass) - await reg.async_load() - hass.data[DATA_REGISTRY] = reg - evt.set() - return reg - - if isinstance(reg_or_evt, Event): - evt = reg_or_evt - await evt.wait() - return cast(AreaRegistry, hass.data.get(DATA_REGISTRY)) - - return cast(AreaRegistry, reg_or_evt) +def normalize_area_name(area_name: str) -> str: + """Normalize an area name by removing whitespace and case folding.""" + return area_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 97445b8cee2..7b7b53d3c0f 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -1,4 +1,6 @@ """Helper to check the configuration file.""" +from __future__ import annotations + from collections import OrderedDict import logging import os @@ -49,7 +51,7 @@ class HomeAssistantConfig(OrderedDict): message: str, domain: Optional[str] = None, config: Optional[ConfigType] = None, - ) -> "HomeAssistantConfig": + ) -> HomeAssistantConfig: """Add a single error.""" self.errors.append(CheckConfigError(str(message), domain, config)) return self diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 6733b1d3dbd..abeef0f0d68 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -136,7 +136,6 @@ class YamlCollection(ObservableCollection): async def async_load(self, data: List[dict]) -> None: """Load the YAML collection. Overrides existing data.""" - old_ids = set(self.data) change_sets = [] @@ -301,7 +300,10 @@ class IDLessCollection(ObservableCollection): @callback -def attach_entity_component_collection( +def sync_entity_lifecycle( + hass: HomeAssistantType, + domain: str, + platform: str, entity_component: EntityComponent, collection: ObservableCollection, create_entity: Callable[[dict], Entity], @@ -318,8 +320,13 @@ def attach_entity_component_collection( return if change_type == CHANGE_REMOVED: - entity = entities.pop(item_id) - await entity.async_remove() + ent_reg = await entity_registry.async_get_registry(hass) + ent_to_remove = ent_reg.async_get_entity_id(domain, platform, item_id) + if ent_to_remove is not None: + ent_reg.async_remove(ent_to_remove) + else: + await entities[item_id].async_remove(force_remove=True) + entities.pop(item_id) return # CHANGE_UPDATED @@ -328,28 +335,6 @@ def attach_entity_component_collection( collection.async_add_listener(_collection_changed) -@callback -def attach_entity_registry_cleaner( - hass: HomeAssistantType, - domain: str, - platform: str, - collection: ObservableCollection, -) -> None: - """Attach a listener to clean up entity registry on collection changes.""" - - async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: - """Handle a collection change: clean up entity registry on removals.""" - if change_type != CHANGE_REMOVED: - return - - ent_reg = await entity_registry.async_get_registry(hass) - ent_to_remove = ent_reg.async_get_entity_id(domain, platform, item_id) - if ent_to_remove is not None: - ent_reg.async_remove(ent_to_remove) - - collection.async_add_listener(_collection_changed) - - class StorageCollectionWebsocket: """Class to expose storage collection management over websocket.""" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5ace4c91bcf..40087650141 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -36,7 +36,14 @@ from homeassistant.const import ( WEEKDAYS, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.exceptions import ( + ConditionError, + ConditionErrorContainer, + ConditionErrorIndex, + ConditionErrorMessage, + HomeAssistantError, + TemplateError, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.template import Template @@ -108,13 +115,19 @@ async def async_and_from_config( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: """Test and condition.""" - try: - for check in checks: + errors = [] + for index, check in enumerate(checks): + try: if not check(hass, variables): return False - except Exception as ex: # pylint: disable=broad-except - _LOGGER.warning("Error during and-condition: %s", ex) - return False + except ConditionError as ex: + errors.append( + ConditionErrorIndex("and", index=index, total=len(checks), error=ex) + ) + + # Raise the errors if no check was false + if errors: + raise ConditionErrorContainer("and", errors=errors) return True @@ -134,13 +147,20 @@ async def async_or_from_config( def if_or_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: - """Test and condition.""" - try: - for check in checks: + """Test or condition.""" + errors = [] + for index, check in enumerate(checks): + try: if check(hass, variables): return True - except Exception as ex: # pylint: disable=broad-except - _LOGGER.warning("Error during or-condition: %s", ex) + except ConditionError as ex: + errors.append( + ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ) + + # Raise the errors if no check was true + if errors: + raise ConditionErrorContainer("or", errors=errors) return False @@ -161,12 +181,19 @@ async def async_not_from_config( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: """Test not condition.""" - try: - for check in checks: + errors = [] + for index, check in enumerate(checks): + try: if check(hass, variables): return False - except Exception as ex: # pylint: disable=broad-except - _LOGGER.warning("Error during not-condition: %s", ex) + except ConditionError as ex: + errors.append( + ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ) + + # Raise the errors if no check was true + if errors: + raise ConditionErrorContainer("not", errors=errors) return True @@ -204,11 +231,23 @@ def async_numeric_state( attribute: Optional[str] = None, ) -> bool: """Test a numeric state condition.""" + if entity is None: + raise ConditionErrorMessage("numeric_state", "no entity specified") + if isinstance(entity, str): + entity_id = entity entity = hass.states.get(entity) - if entity is None or (attribute is not None and attribute not in entity.attributes): - return False + if entity is None: + raise ConditionErrorMessage("numeric_state", f"unknown entity {entity_id}") + else: + 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", + ) value: Any = None if value_template is None: @@ -222,43 +261,62 @@ def async_numeric_state( try: value = value_template.async_render(variables) except TemplateError as ex: - _LOGGER.error("Template error: %s", ex) - return False + raise ConditionErrorMessage( + "numeric_state", f"template error: {ex}" + ) from ex if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + raise ConditionErrorMessage( + "numeric_state", f"state of {entity_id} is unavailable" + ) try: fvalue = float(value) - except ValueError: - _LOGGER.warning( - "Value cannot be processed as a number: %s (Offending entity: %s)", - entity, - value, - ) - return False + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"entity {entity_id} state '{value}' cannot be processed as a number", + ) from ex if below is not None: if isinstance(below, str): below_entity = hass.states.get(below) - if ( - not below_entity - or below_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or fvalue >= float(below_entity.state) + if not below_entity or below_entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, ): - return False + raise ConditionErrorMessage( + "numeric_state", f"the 'below' entity {below} is unavailable" + ) + try: + if fvalue >= float(below_entity.state): + return False + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"the 'below' entity {below} state '{below_entity.state}' cannot be processed as a number", + ) from ex elif fvalue >= below: return False if above is not None: if isinstance(above, str): above_entity = hass.states.get(above) - if ( - not above_entity - or above_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or fvalue <= float(above_entity.state) + if not above_entity or above_entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, ): - return False + raise ConditionErrorMessage( + "numeric_state", f"the 'above' entity {above} is unavailable" + ) + try: + if fvalue <= float(above_entity.state): + return False + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"the 'above' entity {above} state '{above_entity.state}' cannot be processed as a number", + ) from ex elif fvalue <= above: return False @@ -284,12 +342,25 @@ def async_numeric_state_from_config( if value_template is not None: value_template.hass = hass - return all( - async_numeric_state( - hass, entity_id, below, above, value_template, variables, attribute - ) - for entity_id in entity_ids - ) + errors = [] + for index, entity_id in enumerate(entity_ids): + try: + if not async_numeric_state( + hass, entity_id, below, above, value_template, variables, attribute + ): + return False + except ConditionError as ex: + errors.append( + ConditionErrorIndex( + "numeric_state", index=index, total=len(entity_ids), error=ex + ) + ) + + # Raise the errors if no check was false + if errors: + raise ConditionErrorContainer("numeric_state", errors=errors) + + return True return if_numeric_state @@ -305,11 +376,22 @@ def state( Async friendly. """ + if entity is None: + raise ConditionErrorMessage("state", "no entity specified") + if isinstance(entity, str): + entity_id = entity entity = hass.states.get(entity) - if entity is None or (attribute is not None and attribute not in entity.attributes): - return False + if entity is None: + raise ConditionErrorMessage("state", f"unknown entity {entity_id}") + else: + 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" + ) assert isinstance(entity, State) @@ -330,7 +412,9 @@ def state( ): state_entity = hass.states.get(req_state_value) if not state_entity: - continue + raise ConditionErrorMessage( + "state", f"the 'state' entity {req_state_value} is unavailable" + ) state_value = state_entity.state is_state = value == state_value if is_state: @@ -358,10 +442,23 @@ def state_from_config( def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" - return all( - state(hass, entity_id, req_states, for_period, attribute) - for entity_id in entity_ids - ) + errors = [] + for index, entity_id in enumerate(entity_ids): + try: + if not state(hass, entity_id, req_states, for_period, attribute): + return False + except ConditionError as ex: + errors.append( + ConditionErrorIndex( + "state", index=index, total=len(entity_ids), error=ex + ) + ) + + # Raise the errors if no check was false + if errors: + raise ConditionErrorContainer("state", errors=errors) + + return True return if_state @@ -455,8 +552,7 @@ def async_template( try: value: str = value_template.async_render(variables, parse_result=False) except TemplateError as ex: - _LOGGER.error("Error during template condition: %s", ex) - return False + raise ConditionErrorMessage("template", str(ex)) from ex return value.lower() == "true" @@ -499,7 +595,7 @@ def time( elif isinstance(after, str): after_entity = hass.states.get(after) if not after_entity: - return False + raise ConditionErrorMessage("time", f"unknown 'after' entity {after}") after = dt_util.dt.time( after_entity.attributes.get("hour", 23), after_entity.attributes.get("minute", 59), @@ -511,7 +607,7 @@ def time( elif isinstance(before, str): before_entity = hass.states.get(before) if not before_entity: - return False + raise ConditionErrorMessage("time", f"unknown 'before' entity {before}") before = dt_util.dt.time( before_entity.attributes.get("hour", 23), before_entity.attributes.get("minute", 59), @@ -565,23 +661,40 @@ def zone( Async friendly. """ + if zone_ent is None: + raise ConditionErrorMessage("zone", "no zone specified") + if isinstance(zone_ent, str): + zone_ent_id = zone_ent zone_ent = hass.states.get(zone_ent) - if zone_ent is None: - return False - - if isinstance(entity, str): - entity = hass.states.get(entity) + if zone_ent is None: + raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") if entity is None: - return False + raise ConditionErrorMessage("zone", "no entity specified") + + if isinstance(entity, str): + entity_id = entity + entity = hass.states.get(entity) + + if entity is None: + raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") + else: + entity_id = entity.entity_id latitude = entity.attributes.get(ATTR_LATITUDE) longitude = entity.attributes.get(ATTR_LONGITUDE) - if latitude is None or longitude is None: - return False + if latitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'latitude' attribute" + ) + + if longitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'longitude' attribute" + ) return zone_cmp.in_zone( zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) @@ -599,13 +712,31 @@ def zone_from_config( def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" - return all( - any( - zone(hass, zone_entity_id, entity_id) - for zone_entity_id in zone_entity_ids - ) - for entity_id in entity_ids - ) + errors = [] + + all_ok = True + for entity_id in entity_ids: + entity_ok = False + for zone_entity_id in zone_entity_ids: + try: + if zone(hass, zone_entity_id, entity_id): + entity_ok = True + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + f"error matching {entity_id} with {zone_entity_id}: {ex.message}", + ) + ) + + if not entity_ok: + all_ok = False + + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) + + return all_ok return if_in_zone diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index acf6139708a..422f940e98e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -266,7 +266,7 @@ def entity_id(value: Any) -> str: if valid_entity_id(str_value): return str_value - raise vol.Invalid(f"Entity ID {value} is an invalid entity id") + raise vol.Invalid(f"Entity ID {value} is an invalid entity ID") def entity_ids(value: Union[str, List]) -> List[str]: @@ -549,7 +549,6 @@ unit_system = vol.All( def template(value: Optional[Any]) -> template_helper.Template: """Validate a jinja2 template.""" - if value is None: raise vol.Invalid("template value is None") if isinstance(value, (list, dict, template_helper.Template)): @@ -566,13 +565,12 @@ def template(value: Optional[Any]) -> template_helper.Template: def dynamic_template(value: Optional[Any]) -> template_helper.Template: """Validate a dynamic (non static) jinja2 template.""" - if value is None: raise vol.Invalid("template value is None") if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") if not template_helper.is_template_string(str(value)): - raise vol.Invalid("template value does not contain a dynmamic template") + raise vol.Invalid("template value does not contain a dynamic template") template_value = template_helper.Template(str(value)) # type: ignore try: @@ -849,11 +847,18 @@ PLATFORM_SCHEMA = vol.Schema( PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) ENTITY_SERVICE_FIELDS = { - vol.Optional(ATTR_ENTITY_ID): comp_entity_ids, - vol.Optional(ATTR_DEVICE_ID): vol.Any( - ENTITY_MATCH_NONE, vol.All(ensure_list, [str]) + # Either accept static entity IDs, a single dynamic template or a mixed list + # of static and dynamic templates. While this could be solved with a single + # complex template, handling it like this, keeps config validation useful. + vol.Optional(ATTR_ENTITY_ID): vol.Any( + comp_entity_ids, dynamic_template, vol.All(list, template_complex) + ), + vol.Optional(ATTR_DEVICE_ID): vol.Any( + ENTITY_MATCH_NONE, vol.All(ensure_list, [vol.Any(dynamic_template, str)]) + ), + vol.Optional(ATTR_AREA_ID): vol.Any( + ENTITY_MATCH_NONE, vol.All(ensure_list, [vol.Any(dynamic_template, str)]) ), - vol.Optional(ATTR_AREA_ID): vol.Any(ENTITY_MATCH_NONE, vol.All(ensure_list, [str])), } @@ -890,9 +895,11 @@ def script_action(value: Any) -> dict: SCRIPT_SCHEMA = vol.All(ensure_list, [script_action]) +SCRIPT_ACTION_BASE_SCHEMA = {vol.Optional(CONF_ALIAS): string} + EVENT_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_EVENT): string, vol.Optional(CONF_EVENT_DATA): vol.All(dict, template_complex), vol.Optional(CONF_EVENT_DATA_TEMPLATE): vol.All(dict, template_complex), @@ -902,7 +909,7 @@ EVENT_SCHEMA = vol.Schema( SERVICE_SCHEMA = vol.All( vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Exclusive(CONF_SERVICE, "service name"): vol.Any( service, dynamic_template ), @@ -922,9 +929,12 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any( vol.Coerce(float), vol.All(str, entity_domain("input_number")) ) +CONDITION_BASE_SCHEMA = {vol.Optional(CONF_ALIAS): string} + NUMERIC_STATE_CONDITION_SCHEMA = vol.All( vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "numeric_state", vol.Required(CONF_ENTITY_ID): entity_ids, vol.Optional(CONF_ATTRIBUTE): str, @@ -937,6 +947,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.Optional(CONF_ATTRIBUTE): str, @@ -977,6 +988,7 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name SUN_CONDITION_SCHEMA = vol.All( vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "sun", vol.Optional("before"): sun_event, vol.Optional("before_offset"): time_period, @@ -991,6 +1003,7 @@ SUN_CONDITION_SCHEMA = vol.All( TEMPLATE_CONDITION_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "template", vol.Required(CONF_VALUE_TEMPLATE): template, } @@ -999,6 +1012,7 @@ TEMPLATE_CONDITION_SCHEMA = vol.Schema( TIME_CONDITION_SCHEMA = vol.All( vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "time", "before": vol.Any(time, vol.All(str, entity_domain("input_datetime"))), "after": vol.Any(time, vol.All(str, entity_domain("input_datetime"))), @@ -1010,6 +1024,7 @@ TIME_CONDITION_SCHEMA = vol.All( ZONE_CONDITION_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "zone", vol.Required(CONF_ENTITY_ID): entity_ids, "zone": entity_ids, @@ -1021,6 +1036,7 @@ ZONE_CONDITION_SCHEMA = vol.Schema( AND_CONDITION_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "and", vol.Required(CONF_CONDITIONS): vol.All( ensure_list, @@ -1032,6 +1048,7 @@ AND_CONDITION_SCHEMA = vol.Schema( OR_CONDITION_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "or", vol.Required(CONF_CONDITIONS): vol.All( ensure_list, @@ -1043,6 +1060,7 @@ OR_CONDITION_SCHEMA = vol.Schema( NOT_CONDITION_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "not", vol.Required(CONF_CONDITIONS): vol.All( ensure_list, @@ -1054,6 +1072,7 @@ NOT_CONDITION_SCHEMA = vol.Schema( DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( { + **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "device", vol.Required(CONF_DEVICE_ID): str, vol.Required(CONF_DOMAIN): str, @@ -1089,14 +1108,14 @@ TRIGGER_SCHEMA = vol.All( _SCRIPT_DELAY_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_DELAY): positive_time_period_template, } ) _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_WAIT_TEMPLATE): template, vol.Optional(CONF_TIMEOUT): positive_time_period_template, vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean, @@ -1104,16 +1123,22 @@ _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema( ) DEVICE_ACTION_BASE_SCHEMA = vol.Schema( - {vol.Required(CONF_DEVICE_ID): string, vol.Required(CONF_DOMAIN): str} + { + **SCRIPT_ACTION_BASE_SCHEMA, + vol.Required(CONF_DEVICE_ID): string, + vol.Required(CONF_DOMAIN): str, + } ) DEVICE_ACTION_SCHEMA = DEVICE_ACTION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -_SCRIPT_SCENE_SCHEMA = vol.Schema({vol.Required(CONF_SCENE): entity_domain("scene")}) +_SCRIPT_SCENE_SCHEMA = vol.Schema( + {**SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_SCENE): entity_domain("scene")} +) _SCRIPT_REPEAT_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_REPEAT): vol.All( { vol.Exclusive(CONF_COUNT, "repeat"): vol.Any(vol.Coerce(int), template), @@ -1132,11 +1157,12 @@ _SCRIPT_REPEAT_SCHEMA = vol.Schema( _SCRIPT_CHOOSE_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_CHOOSE): vol.All( ensure_list, [ { + vol.Optional(CONF_ALIAS): string, vol.Required(CONF_CONDITIONS): vol.All( ensure_list, [CONDITION_SCHEMA] ), @@ -1150,7 +1176,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_WAIT_FOR_TRIGGER): TRIGGER_SCHEMA, vol.Optional(CONF_TIMEOUT): positive_time_period_template, vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean, @@ -1159,7 +1185,7 @@ _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema( _SCRIPT_SET_SCHEMA = vol.Schema( { - vol.Optional(CONF_ALIAS): string, + **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA, } ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index c449d2ed4d0..d311538f27f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,21 +2,23 @@ from collections import OrderedDict import logging import time -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union, cast import attr from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, callback +from homeassistant.loader import bind_hass import homeassistant.util.uuid as uuid_util from .debounce import Debouncer -from .singleton import singleton from .typing import UNDEFINED, HomeAssistantType, UndefinedType # mypy: disallow_any_generics if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from . import entity_registry _LOGGER = logging.getLogger(__name__) @@ -37,6 +39,7 @@ IDX_IDENTIFIERS = "identifiers" REGISTERED_DEVICE = "registered" DELETED_DEVICE = "deleted" +DISABLED_CONFIG_ENTRY = "config_entry" DISABLED_INTEGRATION = "integration" DISABLED_USER = "user" @@ -65,12 +68,14 @@ class DeviceEntry: default=None, validator=attr.validators.in_( ( + DISABLED_CONFIG_ENTRY, DISABLED_INTEGRATION, DISABLED_USER, None, ) ), ) + suggested_area: Optional[str] = attr.ib(default=None) @property def disabled(self) -> bool: @@ -251,6 +256,7 @@ class DeviceRegistry: via_device: Optional[Tuple[str, str]] = None, # To disable a device if it gets created disabled_by: Union[str, None, UndefinedType] = UNDEFINED, + suggested_area: Union[str, None, UndefinedType] = UNDEFINED, ) -> Optional[DeviceEntry]: """Get device. Create if it doesn't exist.""" if not identifiers and not connections: @@ -304,6 +310,7 @@ class DeviceRegistry: sw_version=sw_version, entry_type=entry_type, disabled_by=disabled_by, + suggested_area=suggested_area, ) @callback @@ -321,6 +328,7 @@ class DeviceRegistry: via_device_id: Union[str, None, UndefinedType] = UNDEFINED, remove_config_entry_id: Union[str, UndefinedType] = UNDEFINED, disabled_by: Union[str, None, UndefinedType] = UNDEFINED, + suggested_area: Union[str, None, UndefinedType] = UNDEFINED, ) -> Optional[DeviceEntry]: """Update properties of a device.""" return self._async_update_device( @@ -335,6 +343,7 @@ class DeviceRegistry: via_device_id=via_device_id, remove_config_entry_id=remove_config_entry_id, disabled_by=disabled_by, + suggested_area=suggested_area, ) @callback @@ -356,6 +365,7 @@ class DeviceRegistry: area_id: Union[str, None, UndefinedType] = UNDEFINED, name_by_user: Union[str, None, UndefinedType] = UNDEFINED, disabled_by: Union[str, None, UndefinedType] = UNDEFINED, + suggested_area: Union[str, None, UndefinedType] = UNDEFINED, ) -> Optional[DeviceEntry]: """Update device attributes.""" old = self.devices[device_id] @@ -364,6 +374,16 @@ class DeviceRegistry: config_entries = old.config_entries + if ( + suggested_area not in (UNDEFINED, None, "") + and area_id is UNDEFINED + and old.area_id is None + ): + area = self.hass.helpers.area_registry.async_get( + self.hass + ).async_get_or_create(suggested_area) + area_id = area.id + if ( add_config_entry_id is not UNDEFINED and add_config_entry_id not in old.config_entries @@ -403,6 +423,7 @@ class DeviceRegistry: ("entry_type", entry_type), ("via_device_id", via_device_id), ("disabled_by", disabled_by), + ("suggested_area", suggested_area), ): if value is not UNDEFINED and value != getattr(old, attr_name): changes[attr_name] = value @@ -593,12 +614,26 @@ class DeviceRegistry: self._async_update_device(dev_id, area_id=None) -@singleton(DATA_REGISTRY) +@callback +def async_get(hass: HomeAssistantType) -> DeviceRegistry: + """Get device registry.""" + return cast(DeviceRegistry, hass.data[DATA_REGISTRY]) + + +async def async_load(hass: HomeAssistantType) -> None: + """Load device registry.""" + assert DATA_REGISTRY not in hass.data + hass.data[DATA_REGISTRY] = DeviceRegistry(hass) + await hass.data[DATA_REGISTRY].async_load() + + +@bind_hass async def async_get_registry(hass: HomeAssistantType) -> DeviceRegistry: - """Create entity registry.""" - reg = DeviceRegistry(hass) - await reg.async_load() - return reg + """Get device registry. + + This is deprecated and will be removed in the future. Use async_get instead. + """ + return async_get(hass) @callback @@ -619,6 +654,34 @@ def async_entries_for_config_entry( ] +@callback +def async_config_entry_disabled_by_changed( + registry: DeviceRegistry, config_entry: "ConfigEntry" +) -> None: + """Handle a config entry being disabled or enabled. + + 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. + """ + + devices = async_entries_for_config_entry(registry, config_entry.entry_id) + + if not config_entry.disabled_by: + for device in devices: + if device.disabled_by != DISABLED_CONFIG_ENTRY: + continue + registry.async_update_device(device.id, disabled_by=None) + return + + for device in devices: + if device.disabled: + # Device already disabled, do not overwrite + continue + registry.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY) + + @callback def async_cleanup( hass: HomeAssistantType, @@ -672,25 +735,34 @@ def async_setup_cleanup(hass: HomeAssistantType, dev_reg: DeviceRegistry) -> Non ) async def entity_registry_changed(event: Event) -> None: - """Handle entity updated or removed.""" + """Handle entity updated or removed dispatch.""" + await debounced_cleanup.async_call() + + @callback + def entity_registry_changed_filter(event: Event) -> bool: + """Handle entity updated or removed filter.""" if ( event.data["action"] == "update" and "device_id" not in event.data["changes"] ) or event.data["action"] == "create": - return + return False - await debounced_cleanup.async_call() + return True if hass.is_running: hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, entity_registry_changed + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + entity_registry_changed, + event_filter=entity_registry_changed_filter, ) return async def startup_clean(event: Event) -> None: """Clean up on startup.""" hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, entity_registry_changed + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + entity_registry_changed, + event_filter=entity_registry_changed_filter, ) await debounced_cleanup.async_call() diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index acde8d73a50..0770e6798f1 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -9,6 +9,7 @@ from typing import Any, Callable, Collection, Dict, Optional, Union from homeassistant import core, setup from homeassistant.const import ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe @@ -16,10 +17,14 @@ from homeassistant.util.async_ import run_callback_threadsafe EVENT_LOAD_PLATFORM = "load_platform.{}" ATTR_PLATFORM = "platform" +# mypy: disallow-any-generics + @bind_hass def listen( - hass: core.HomeAssistant, service: Union[str, Collection[str]], callback: Callable + hass: core.HomeAssistant, + service: Union[str, Collection[str]], + callback: CALLBACK_TYPE, ) -> None: """Set up listener for discovery of specific service. @@ -31,7 +36,9 @@ def listen( @core.callback @bind_hass def async_listen( - hass: core.HomeAssistant, service: Union[str, Collection[str]], callback: Callable + hass: core.HomeAssistant, + service: Union[str, Collection[str]], + callback: CALLBACK_TYPE, ) -> None: """Set up listener for discovery of specific service. @@ -94,7 +101,7 @@ async def async_discover( @bind_hass def listen_platform( - hass: core.HomeAssistant, component: str, callback: Callable + hass: core.HomeAssistant, component: str, callback: CALLBACK_TYPE ) -> None: """Register a platform loader listener.""" run_callback_threadsafe( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 03342a9f235..7d0e38ab119 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -65,7 +65,6 @@ def async_generate_entity_id( hass: Optional[HomeAssistant] = None, ) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - name = (name or DEVICE_DEFAULT_NAME).lower() preferred_string = entity_id_format.format(slugify(name)) @@ -530,8 +529,16 @@ class Entity(ABC): await self.async_added_to_hass() self.async_write_ha_state() - async def async_remove(self) -> None: - """Remove entity from Home Assistant.""" + async def async_remove(self, *, force_remove: bool = False) -> None: + """Remove entity from Home Assistant. + + If the entity has a non disabled entry in the entity registry, + the entity's state will be set to unavailable, in the same way + as when the entity registry is loaded. + + If the entity doesn't have a non disabled entry in the entity registry, + or if force_remove=True, its state will be removed. + """ assert self.hass is not None if self.platform and not self._added: @@ -548,7 +555,16 @@ class Entity(ABC): await self.async_internal_will_remove_from_hass() await self.async_will_remove_from_hass() - self.hass.states.async_remove(self.entity_id, context=self._context) + # Check if entry still exists in entity registry (e.g. unloading config entry) + if ( + not force_remove + and self.registry_entry + and not self.registry_entry.disabled + ): + # Set the entity's state will to unavailable + ATTR_RESTORED: True + self.registry_entry.write_unavailable_state(self.hass) + else: + self.hass.states.async_remove(self.entity_id, context=self._context) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass. @@ -606,6 +622,7 @@ class Entity(ABC): data = event.data if data["action"] == "remove": await self.async_removed_from_registry() + self.registry_entry = None await self.async_remove() if data["action"] != "update": @@ -617,7 +634,7 @@ class Entity(ABC): self.registry_entry = ent_reg.async_get(data["entity_id"]) assert self.registry_entry is not None - if self.registry_entry.disabled_by is not None: + if self.registry_entry.disabled: await self.async_remove() return @@ -626,7 +643,7 @@ class Entity(ABC): self.async_write_ha_state() return - await self.async_remove() + await self.async_remove(force_remove=True) assert self.platform is not None self.entity_id = self.registry_entry.entity_id diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 0f1f04e3aec..6fb8696d845 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -272,7 +272,9 @@ class EntityComponent: if found: await found.async_remove_entity(entity_id) - async def async_prepare_reload(self, *, skip_reset: bool = False) -> Optional[dict]: + async def async_prepare_reload( + self, *, skip_reset: bool = False + ) -> Optional[ConfigType]: """Prepare reloading this entity component. This method must be run in the event loop. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index bd687ab7ce8..2caf7fe46ab 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,4 +1,6 @@ """Class to manage the entities for a single platform.""" +from __future__ import annotations + import asyncio from contextvars import ContextVar from datetime import datetime, timedelta @@ -245,7 +247,7 @@ class EntityPlatform: warn_task.cancel() def _schedule_add_entities( - self, new_entities: Iterable["Entity"], update_before_add: bool = False + self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform, synchronously.""" run_callback_threadsafe( @@ -257,7 +259,7 @@ class EntityPlatform: @callback def _async_schedule_add_entities( - self, new_entities: Iterable["Entity"], update_before_add: bool = False + self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform async.""" task = self.hass.async_create_task( @@ -268,7 +270,7 @@ class EntityPlatform: self._tasks.append(task) def add_entities( - self, new_entities: Iterable["Entity"], update_before_add: bool = False + self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Add entities for a single platform.""" # That avoid deadlocks @@ -284,7 +286,7 @@ class EntityPlatform: ).result() async def async_add_entities( - self, new_entities: Iterable["Entity"], update_before_add: bool = False + self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Add entities for a single platform async. @@ -397,6 +399,7 @@ class EntityPlatform: "sw_version", "entry_type", "via_device", + "suggested_area", ): if key in device_info: processed_dev_info[key] = device_info[key] @@ -517,7 +520,7 @@ class EntityPlatform: if not self.entities: return - tasks = [self.async_remove_entity(entity_id) for entity_id in self.entities] + tasks = [entity.async_remove() for entity in self.entities.values()] await asyncio.gather(*tasks) @@ -547,7 +550,7 @@ class EntityPlatform: async def async_extract_from_service( self, service_call: ServiceCall, expand_group: bool = True - ) -> List["Entity"]: + ) -> List[Entity]: """Extract all known and available entities from a service call. Will return an empty list if entities specified but unknown. diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0628c1e0eb5..8a7a4de970a 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -19,6 +19,7 @@ from typing import ( Optional, Tuple, Union, + cast, ) import attr @@ -34,11 +35,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Event, callback, split_entity_id, valid_entity_id +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.loader import bind_hass from homeassistant.util import slugify from homeassistant.util.yaml import load_yaml -from .singleton import singleton from .typing import UNDEFINED, HomeAssistantType, UndefinedType if TYPE_CHECKING: @@ -115,6 +117,33 @@ class RegistryEntry: """Return if entry is disabled.""" return self.disabled_by is not None + @callback + def write_unavailable_state(self, hass: HomeAssistantType) -> None: + """Write the unavailable state to the state machine.""" + attrs: Dict[str, Any] = {ATTR_RESTORED: True} + + 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 + + icon = self.icon or self.original_icon + if icon is not None: + attrs[ATTR_ICON] = icon + + hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) + class EntityRegistry: """Class to hold a registry of entities.""" @@ -285,7 +314,8 @@ class EntityRegistry: ) self.async_schedule_save() - async def async_device_modified(self, event: Event) -> None: + @callback + def async_device_modified(self, event: Event) -> None: """Handle the removal or update of a device. Remove entities from the registry that are associated to a device when @@ -305,9 +335,11 @@ class EntityRegistry: if event.data["action"] != "update": return - device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(self.hass) device = device_registry.async_get(event.data["device_id"]) - if not device.disabled: + + # The device may be deleted already if the event handling is late + if not device or not device.disabled: entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) @@ -317,6 +349,11 @@ class EntityRegistry: self.async_update_entity(entity.entity_id, disabled_by=None) return + if device.disabled_by == dr.DISABLED_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) @@ -367,7 +404,8 @@ class EntityRegistry: """Private facing update properties method.""" old = self.entities[entity_id] - changes = {} + new_values = {} # Dict with new key/value pairs + old_values = {} # Dict with old key/value pairs for attr_name, value in ( ("name", name), @@ -384,7 +422,8 @@ class EntityRegistry: ("original_icon", original_icon), ): if value is not UNDEFINED and value != getattr(old, attr_name): - changes[attr_name] = value + new_values[attr_name] = value + old_values[attr_name] = getattr(old, attr_name) if new_entity_id is not UNDEFINED and new_entity_id != old.entity_id: if self.async_is_registered(new_entity_id): @@ -397,7 +436,8 @@ class EntityRegistry: raise ValueError("New entity ID should be same domain") self.entities.pop(entity_id) - entity_id = changes["entity_id"] = new_entity_id + entity_id = new_values["entity_id"] = new_entity_id + old_values["entity_id"] = old.entity_id if new_unique_id is not UNDEFINED: conflict_entity_id = self.async_get_entity_id( @@ -408,18 +448,19 @@ class EntityRegistry: f"Unique id '{new_unique_id}' is already in use by " f"'{conflict_entity_id}'" ) - changes["unique_id"] = new_unique_id + new_values["unique_id"] = new_unique_id + old_values["unique_id"] = old.unique_id - if not changes: + if not new_values: return old self._remove_index(old) - new = attr.evolve(old, **changes) + new = attr.evolve(old, **new_values) self._register_entry(new) self.async_schedule_save() - data = {"action": "update", "entity_id": entity_id, "changes": list(changes)} + data = {"action": "update", "entity_id": entity_id, "changes": old_values} if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id @@ -539,12 +580,26 @@ class EntityRegistry: self._add_index(entry) -@singleton(DATA_REGISTRY) +@callback +def async_get(hass: HomeAssistantType) -> EntityRegistry: + """Get entity registry.""" + return cast(EntityRegistry, hass.data[DATA_REGISTRY]) + + +async def async_load(hass: HomeAssistantType) -> None: + """Load entity registry.""" + assert DATA_REGISTRY not in hass.data + hass.data[DATA_REGISTRY] = EntityRegistry(hass) + await hass.data[DATA_REGISTRY].async_load() + + +@bind_hass async def async_get_registry(hass: HomeAssistantType) -> EntityRegistry: - """Create entity registry.""" - reg = EntityRegistry(hass) - await reg.async_load() - return reg + """Get entity registry. + + This is deprecated and will be removed in the future. Use async_get instead. + """ + return async_get(hass) @callback @@ -580,6 +635,36 @@ def async_entries_for_config_entry( ] +@callback +def async_config_entry_disabled_by_changed( + registry: EntityRegistry, config_entry: "ConfigEntry" +) -> None: + """Handle a config entry being disabled or enabled. + + Disable entities in the registry that are associated with a config entry when + the config entry is disabled, enable entities in the registry that are associated + with a config entry when the config entry is enabled and the entities are marked + DISABLED_CONFIG_ENTRY. + """ + + entities = async_entries_for_config_entry(registry, config_entry.entry_id) + + if not config_entry.disabled_by: + for entity in entities: + if entity.disabled_by != DISABLED_CONFIG_ENTRY: + continue + registry.async_update_entity(entity.entity_id, disabled_by=None) + return + + for entity in entities: + if entity.disabled: + # Entity already disabled, do not overwrite + continue + registry.async_update_entity( + entity.entity_id, disabled_by=DISABLED_CONFIG_ENTRY + ) + + async def _async_migrate(entities: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: """Migrate the YAML config file to storage helper format.""" return { @@ -595,12 +680,14 @@ def async_setup_entity_restore( ) -> None: """Set up the entity restore mechanism.""" + @callback + def cleanup_restored_states_filter(event: Event) -> bool: + """Clean up restored states filter.""" + return bool(event.data["action"] == "remove") + @callback def cleanup_restored_states(event: Event) -> None: """Clean up restored states.""" - if event.data["action"] != "remove": - return - state = hass.states.get(event.data["entity_id"]) if state is None or not state.attributes.get(ATTR_RESTORED): @@ -608,7 +695,11 @@ def async_setup_entity_restore( hass.states.async_remove(event.data["entity_id"], context=event.context) - hass.bus.async_listen(EVENT_ENTITY_REGISTRY_UPDATED, cleanup_restored_states) + hass.bus.async_listen( + EVENT_ENTITY_REGISTRY_UPDATED, + cleanup_restored_states, + event_filter=cleanup_restored_states_filter, + ) if hass.is_running: return @@ -616,36 +707,13 @@ def async_setup_entity_restore( @callback def _write_unavailable_states(_: Event) -> None: """Make sure state machine contains entry for each registered entity.""" - states = hass.states - existing = set(states.async_entity_ids()) + existing = set(hass.states.async_entity_ids()) for entry in registry.entities.values(): if entry.entity_id in existing or entry.disabled: continue - attrs: Dict[str, Any] = {ATTR_RESTORED: True} - - if entry.capabilities is not None: - attrs.update(entry.capabilities) - - if entry.supported_features is not None: - attrs[ATTR_SUPPORTED_FEATURES] = entry.supported_features - - if entry.device_class is not None: - attrs[ATTR_DEVICE_CLASS] = entry.device_class - - if entry.unit_of_measurement is not None: - attrs[ATTR_UNIT_OF_MEASUREMENT] = entry.unit_of_measurement - - name = entry.name or entry.original_name - if name is not None: - attrs[ATTR_FRIENDLY_NAME] = name - - icon = entry.icon or entry.original_icon - if icon is not None: - attrs[ATTR_ICON] = icon - - states.async_set(entry.entity_id, STATE_UNAVAILABLE, attrs) + entry.write_unavailable_state(hass) hass.bus.async_listen(EVENT_HOMEASSISTANT_START, _write_unavailable_states) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f06ac8aca3f..f496c7088a4 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -116,7 +116,9 @@ class TrackTemplateResult: result: Any -def threaded_listener_factory(async_factory: Callable[..., Any]) -> CALLBACK_TYPE: +def threaded_listener_factory( + async_factory: Callable[..., Any] +) -> Callable[..., CALLBACK_TYPE]: """Convert an async event helper to a threaded one.""" @ft.wraps(async_factory) @@ -178,7 +180,7 @@ def async_track_state_change( job = HassJob(action) @callback - def state_change_listener(event: Event) -> None: + def state_change_filter(event: Event) -> bool: """Handle specific state changes.""" if from_state is not None: old_state = event.data.get("old_state") @@ -186,15 +188,21 @@ def async_track_state_change( old_state = old_state.state if not match_from_state(old_state): - return + return False + if to_state is not None: new_state = event.data.get("new_state") if new_state is not None: new_state = new_state.state if not match_to_state(new_state): - return + return False + return True + + @callback + def state_change_dispatcher(event: Event) -> None: + """Handle specific state changes.""" hass.async_run_hass_job( job, event.data.get("entity_id"), @@ -202,6 +210,14 @@ def async_track_state_change( event.data.get("new_state"), ) + @callback + def state_change_listener(event: Event) -> None: + """Handle specific state changes.""" + if not state_change_filter(event): + return + + state_change_dispatcher(event) + if entity_ids != MATCH_ALL: # If we have a list of entity ids we use # async_track_state_change_event to route @@ -213,7 +229,9 @@ def async_track_state_change( # entity_id. return async_track_state_change_event(hass, entity_ids, state_change_listener) - return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) + return hass.bus.async_listen( + EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter + ) track_state_change = threaded_listener_factory(async_track_state_change) @@ -244,6 +262,11 @@ def async_track_state_change_event( if TRACK_STATE_CHANGE_LISTENER not in hass.data: + @callback + def _async_state_change_filter(event: Event) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("entity_id") in entity_callbacks + @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" @@ -257,11 +280,13 @@ def async_track_state_change_event( hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Error while processing state changed for %s", entity_id + "Error while processing state change for %s", entity_id ) hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, _async_state_change_dispatcher + EVENT_STATE_CHANGED, + _async_state_change_dispatcher, + event_filter=_async_state_change_filter, ) job = HassJob(action) @@ -297,7 +322,6 @@ def _async_remove_indexed_listeners( job: HassJob, ) -> None: """Remove a listener.""" - callbacks = hass.data[data_key] for storage_key in storage_keys: @@ -328,6 +352,12 @@ def async_track_entity_registry_updated_event( if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: + @callback + def _async_entity_registry_updated_filter(event: Event) -> bool: + """Filter entity registry updates by entity_id.""" + entity_id = event.data.get("old_entity_id", event.data["entity_id"]) + return entity_id in entity_callbacks + @callback def _async_entity_registry_updated_dispatcher(event: Event) -> None: """Dispatch entity registry updates by entity_id.""" @@ -346,7 +376,9 @@ def async_track_entity_registry_updated_event( ) hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( - EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_updated_dispatcher + EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_registry_updated_dispatcher, + event_filter=_async_entity_registry_updated_filter, ) job = HassJob(action) @@ -403,6 +435,11 @@ def async_track_state_added_domain( if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data: + @callback + def _async_state_change_filter(event: Event) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("old_state") is None + @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" @@ -412,7 +449,9 @@ def async_track_state_added_domain( _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, _async_state_change_dispatcher + EVENT_STATE_CHANGED, + _async_state_change_dispatcher, + event_filter=_async_state_change_filter, ) job = HassJob(action) @@ -449,6 +488,11 @@ def async_track_state_removed_domain( if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data: + @callback + def _async_state_change_filter(event: Event) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("new_state") is None + @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" @@ -458,7 +502,9 @@ def async_track_state_removed_domain( _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, _async_state_change_dispatcher + EVENT_STATE_CHANGED, + _async_state_change_dispatcher, + event_filter=_async_state_change_filter, ) job = HassJob(action) @@ -684,7 +730,6 @@ def async_track_template( Callable to unregister the listener. """ - job = HassJob(action) @callback @@ -1103,7 +1148,6 @@ def async_track_point_in_time( point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - job = action if isinstance(action, HassJob) else HassJob(action) @callback @@ -1327,7 +1371,6 @@ def async_track_utc_time_change( local: bool = False, ) -> CALLBACK_TYPE: """Add a listener that will fire if time matches a pattern.""" - job = HassJob(action) # We do not have to wrap the function with time pattern matching logic # if no pattern given diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index def2508ff92..a0517338ec8 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -70,7 +70,6 @@ def report_integration( Async friendly. """ - found_frame, integration, path = integration_frame index = found_frame.filename.index(path) diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 0f1719b388d..b86223964b3 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -51,7 +51,6 @@ def create_async_httpx_client( This method must be run in the event loop. """ - client = httpx.AsyncClient( verify=verify_ssl, headers={USER_AGENT: SERVER_SOFTWARE}, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f8c8b2c6d8c..1c5d56ccbd1 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,4 +1,6 @@ """Module to coordinate user intentions.""" +from __future__ import annotations + import logging import re from typing import Any, Callable, Dict, Iterable, Optional @@ -29,7 +31,7 @@ SPEECH_TYPE_SSML = "ssml" @callback @bind_hass -def async_register(hass: HomeAssistantType, handler: "IntentHandler") -> None: +def async_register(hass: HomeAssistantType, handler: IntentHandler) -> None: """Register an intent with Home Assistant.""" intents = hass.data.get(DATA_KEY) if intents is None: @@ -53,7 +55,7 @@ async def async_handle( slots: Optional[_SlotsType] = None, text_input: Optional[str] = None, context: Optional[Context] = None, -) -> "IntentResponse": +) -> IntentResponse: """Handle an intent.""" handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -131,7 +133,7 @@ class IntentHandler: platforms: Optional[Iterable[str]] = [] @callback - def async_can_handle(self, intent_obj: "Intent") -> bool: + def async_can_handle(self, intent_obj: Intent) -> bool: """Test if an intent can be handled.""" return self.platforms is None or intent_obj.platform in self.platforms @@ -152,7 +154,7 @@ class IntentHandler: return self._slot_schema(slots) # type: ignore - async def async_handle(self, intent_obj: "Intent") -> "IntentResponse": + async def async_handle(self, intent_obj: Intent) -> IntentResponse: """Handle the intent.""" raise NotImplementedError() @@ -195,7 +197,7 @@ class ServiceIntentHandler(IntentHandler): self.service = service self.speech = speech - async def async_handle(self, intent_obj: "Intent") -> "IntentResponse": + async def async_handle(self, intent_obj: Intent) -> IntentResponse: """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) @@ -236,7 +238,7 @@ class Intent: self.context = context @callback - def create_response(self) -> "IntentResponse": + def create_response(self) -> IntentResponse: """Create a response.""" return IntentResponse(self) diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index bca2996dfa2..19058bc3e7f 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -18,9 +18,8 @@ def has_location(state: State) -> bool: Async friendly. """ - # type ignore: https://github.com/python/mypy/issues/7207 return ( - isinstance(state, State) # type: ignore + isinstance(state, State) and isinstance(state.attributes.get(ATTR_LATITUDE), float) and isinstance(state.attributes.get(ATTR_LONGITUDE), float) ) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 4e066eaa13c..21f69dc539a 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -8,13 +8,7 @@ from homeassistant.components import http from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util.network import ( - is_ip_address, - is_local, - is_loopback, - is_private, - normalize_url, -) +from homeassistant.util.network import is_ip_address, is_loopback, normalize_url TYPE_URL_INTERNAL = "internal_url" TYPE_URL_EXTERNAL = "external_url" @@ -151,19 +145,6 @@ def _get_internal_url( ): return normalize_url(str(internal_url)) - # Fallback to old base_url - try: - return _get_deprecated_base_url( - hass, - internal=True, - allow_ip=allow_ip, - require_current_request=require_current_request, - require_ssl=require_ssl, - require_standard_port=require_standard_port, - ) - except NoURLAvailableError: - pass - # Fallback to detected local IP if allow_ip and not ( require_ssl or hass.config.api is None or hass.config.api.use_ssl @@ -217,17 +198,6 @@ def _get_external_url( ): return normalize_url(str(external_url)) - try: - return _get_deprecated_base_url( - hass, - allow_ip=allow_ip, - require_current_request=require_current_request, - require_ssl=require_ssl, - require_standard_port=require_standard_port, - ) - except NoURLAvailableError: - pass - if allow_cloud: try: return _get_cloud_url(hass, require_current_request=require_current_request) @@ -250,50 +220,3 @@ def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) - return normalize_url(str(cloud_url)) raise NoURLAvailableError - - -@bind_hass -def _get_deprecated_base_url( - hass: HomeAssistant, - *, - internal: bool = False, - allow_ip: bool = True, - require_current_request: bool = False, - require_ssl: bool = False, - require_standard_port: bool = False, -) -> str: - """Work with the deprecated `base_url`, used as fallback.""" - if hass.config.api is None or not hass.config.api.deprecated_base_url: - raise NoURLAvailableError - - base_url = yarl.URL(hass.config.api.deprecated_base_url) - # Rules that apply to both internal and external - if ( - (allow_ip or not is_ip_address(str(base_url.host))) - and (not require_current_request or base_url.host == _get_request_host()) - and (not require_ssl or base_url.scheme == "https") - and (not require_standard_port or base_url.is_default_port()) - ): - # Check to ensure an internal URL - if internal and ( - str(base_url.host).endswith(".local") - or ( - is_ip_address(str(base_url.host)) - and not is_loopback(ip_address(base_url.host)) - and is_private(ip_address(base_url.host)) - ) - ): - return normalize_url(str(base_url)) - - # Check to ensure an external URL (a little) - if ( - not internal - and not str(base_url.host).endswith(".local") - and not ( - is_ip_address(str(base_url.host)) - and is_local(ip_address(str(base_url.host))) - ) - ): - return normalize_url(str(base_url)) - - raise NoURLAvailableError diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index e596027b7e1..4a768a79320 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Any, Dict, Iterable, List, Optional +from typing import Dict, Iterable, List, Optional from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -10,7 +10,7 @@ from homeassistant.core import Event, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity_platform import EntityPlatform, async_get_platforms -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -49,7 +49,7 @@ async def _resetup_platform( hass: HomeAssistantType, integration_name: str, integration_platform: str, - unprocessed_conf: Dict, + unprocessed_conf: ConfigType, ) -> None: """Resetup a platform.""" integration = await async_get_integration(hass, integration_platform) @@ -129,7 +129,7 @@ async def _async_reconfig_platform( async def async_integration_yaml_config( hass: HomeAssistantType, integration_name: str -) -> Optional[Dict[Any, Any]]: +) -> Optional[ConfigType]: """Fetch the latest yaml configuration for an integration.""" integration = await async_get_integration(hass, integration_name) @@ -157,13 +157,11 @@ async def async_setup_reload_service( hass: HomeAssistantType, domain: str, platforms: Iterable ) -> None: """Create the reload service for the domain.""" - if hass.services.has_service(domain, SERVICE_RELOAD): return async def _reload_config(call: Event) -> None: """Reload the platforms.""" - await async_reload_integration_platforms(hass, domain, platforms) hass.bus.async_fire(f"event_{domain}_reloaded", context=call.context) @@ -176,7 +174,6 @@ def setup_reload_service( hass: HomeAssistantType, domain: str, platforms: Iterable ) -> None: """Sync version of async_setup_reload_service.""" - asyncio.run_coroutine_threadsafe( async_setup_reload_service(hass, domain, platforms), hass.loop, diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 97069913c80..4f738887ce3 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -1,4 +1,6 @@ """Support for restoring entity states on startup.""" +from __future__ import annotations + import asyncio from datetime import datetime, timedelta import logging @@ -48,7 +50,7 @@ class StoredState: return {"state": self.state.as_dict(), "last_seen": self.last_seen} @classmethod - def from_dict(cls, json_dict: Dict) -> "StoredState": + def from_dict(cls, json_dict: Dict) -> StoredState: """Initialize a stored state from a dict.""" last_seen = json_dict["last_seen"] @@ -62,11 +64,11 @@ class RestoreStateData: """Helper class for managing the helper saved data.""" @classmethod - async def async_get_instance(cls, hass: HomeAssistant) -> "RestoreStateData": + async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: """Get the singleton instance of this data helper.""" @singleton(DATA_RESTORE_STATE_TASK) - async def load_instance(hass: HomeAssistant) -> "RestoreStateData": + async def load_instance(hass: HomeAssistant) -> RestoreStateData: """Get the singleton instance of this data helper.""" data = cls(hass) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index f197664f7e6..e4eb0d4a901 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -18,7 +18,7 @@ from typing import ( cast, ) -from async_timeout import timeout +import async_timeout import voluptuous as vol from homeassistant import exceptions @@ -44,6 +44,7 @@ from homeassistant.const import ( CONF_REPEAT, CONF_SCENE, CONF_SEQUENCE, + CONF_SERVICE, CONF_TARGET, CONF_TIMEOUT, CONF_UNTIL, @@ -234,6 +235,13 @@ class _ScriptRun: msg, *args, level=level, **kwargs ) + def _step_log(self, default_message, timeout=None): + self._script.last_action = self._action.get(CONF_ALIAS, default_message) + _timeout = ( + "" if timeout is None else f" (timeout: {timedelta(seconds=timeout)})" + ) + self._log("Executing step %s%s", self._script.last_action, _timeout) + async def async_run(self) -> None: """Run script.""" try: @@ -326,13 +334,12 @@ class _ScriptRun: """Handle delay.""" delay = self._get_pos_time_period_template(CONF_DELAY) - self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}") - self._log("Executing step %s", self._script.last_action) + self._step_log(f"delay {delay}") delay = delay.total_seconds() self._changed() try: - async with timeout(delay): + async with async_timeout.timeout(delay): await self._stop.wait() except asyncio.TimeoutError: pass @@ -340,18 +347,13 @@ class _ScriptRun: async def _async_wait_template_step(self): """Handle a wait template.""" if CONF_TIMEOUT in self._action: - delay = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() + timeout = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() else: - delay = None + timeout = None - self._script.last_action = self._action.get(CONF_ALIAS, "wait template") - self._log( - "Executing step %s%s", - self._script.last_action, - "" if delay is None else f" (timeout: {timedelta(seconds=delay)})", - ) + self._step_log("wait template", timeout) - self._variables["wait"] = {"remaining": delay, "completed": False} + self._variables["wait"] = {"remaining": timeout, "completed": False} wait_template = self._action[CONF_WAIT_TEMPLATE] wait_template.hass = self._hass @@ -365,7 +367,7 @@ class _ScriptRun: def async_script_wait(entity_id, from_s, to_s): """Handle script after template condition is true.""" self._variables["wait"] = { - "remaining": to_context.remaining if to_context else delay, + "remaining": to_context.remaining if to_context else timeout, "completed": True, } done.set() @@ -381,7 +383,7 @@ class _ScriptRun: self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with timeout(delay) as to_context: + async with async_timeout.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): @@ -430,17 +432,16 @@ class _ScriptRun: async def _async_call_service_step(self): """Call the service specified in the action.""" - self._script.last_action = self._action.get(CONF_ALIAS, "call service") - self._log("Executing step %s", self._script.last_action) + self._step_log("call service") - domain, service_name, service_data = service.async_prepare_call_from_config( + params = service.async_prepare_call_from_config( self._hass, self._action, self._variables ) running_script = ( - domain == "automation" - and service_name == "trigger" - or domain in ("python_script", "script") + params[CONF_DOMAIN] == "automation" + and params[CONF_SERVICE] == "trigger" + or params[CONF_DOMAIN] in ("python_script", "script") ) # If this might start a script then disable the call timeout. # Otherwise use the normal service call limit. @@ -451,9 +452,7 @@ class _ScriptRun: service_task = self._hass.async_create_task( self._hass.services.async_call( - domain, - service_name, - service_data, + **params, blocking=True, context=self._context, limit=limit, @@ -468,8 +467,7 @@ class _ScriptRun: async def _async_device_step(self): """Perform the device automation specified in the action.""" - self._script.last_action = self._action.get(CONF_ALIAS, "device automation") - self._log("Executing step %s", self._script.last_action) + self._step_log("device automation") platform = await device_automation.async_get_device_automation_platform( self._hass, self._action[CONF_DOMAIN], "action" ) @@ -479,8 +477,7 @@ class _ScriptRun: async def _async_scene_step(self): """Activate the scene specified in the action.""" - self._script.last_action = self._action.get(CONF_ALIAS, "activate scene") - self._log("Executing step %s", self._script.last_action) + self._step_log("activate scene") await self._hass.services.async_call( scene.DOMAIN, SERVICE_TURN_ON, @@ -491,10 +488,7 @@ class _ScriptRun: async def _async_event_step(self): """Fire an event.""" - self._script.last_action = self._action.get( - CONF_ALIAS, self._action[CONF_EVENT] - ) - self._log("Executing step %s", self._script.last_action) + self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) event_data = {} for conf in [CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE]: if conf not in self._action: @@ -519,7 +513,12 @@ class _ScriptRun: CONF_ALIAS, self._action[CONF_CONDITION] ) cond = await self._async_get_condition(self._action) - check = cond(self._hass, self._variables) + try: + check = cond(self._hass, self._variables) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) + check = False + self._log("Test condition %s: %s", self._script.last_action, check) if not check: raise _StopScript @@ -570,10 +569,15 @@ class _ScriptRun: ] for iteration in itertools.count(1): set_repeat_var(iteration) - if self._stop.is_set() or not all( - cond(self._hass, self._variables) for cond in conditions - ): + try: + if self._stop.is_set() or not all( + cond(self._hass, self._variables) for cond in conditions + ): + break + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'while' evaluation:\n%s", ex) break + await async_run_sequence(iteration) elif CONF_UNTIL in repeat: @@ -583,9 +587,13 @@ class _ScriptRun: for iteration in itertools.count(1): set_repeat_var(iteration) await async_run_sequence(iteration) - if self._stop.is_set() or all( - cond(self._hass, self._variables) for cond in conditions - ): + try: + if self._stop.is_set() or all( + cond(self._hass, self._variables) for cond in conditions + ): + break + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'until' evaluation:\n%s", ex) break if saved_repeat_vars: @@ -599,9 +607,14 @@ class _ScriptRun: choose_data = await self._script._async_get_choose_data(self._step) for conditions, script in choose_data["choices"]: - if all(condition(self._hass, self._variables) for condition in conditions): - await self._async_run_script(script) - return + try: + if all( + condition(self._hass, self._variables) for condition in conditions + ): + await self._async_run_script(script) + return + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) if choose_data["default"]: await self._async_run_script(choose_data["default"]) @@ -609,23 +622,20 @@ class _ScriptRun: async def _async_wait_for_trigger_step(self): """Wait for a trigger event.""" if CONF_TIMEOUT in self._action: - delay = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() + timeout = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() else: - delay = None + timeout = None - self._script.last_action = self._action.get(CONF_ALIAS, "wait for trigger") - self._log( - "Executing step %s%s", - self._script.last_action, - "" if delay is None else f" (timeout: {timedelta(seconds=delay)})", - ) + self._step_log("wait for trigger", timeout) variables = {**self._variables} - self._variables["wait"] = {"remaining": delay, "trigger": None} + self._variables["wait"] = {"remaining": timeout, "trigger": None} + + done = asyncio.Event() async def async_done(variables, context=None): self._variables["wait"] = { - "remaining": to_context.remaining if to_context else delay, + "remaining": to_context.remaining if to_context else timeout, "trigger": variables["trigger"], } done.set() @@ -647,12 +657,11 @@ class _ScriptRun: return self._changed() - done = asyncio.Event() tasks = [ self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with timeout(delay) as to_context: + async with async_timeout.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): @@ -666,8 +675,7 @@ class _ScriptRun: async def _async_variables_step(self): """Set a variable value.""" - self._script.last_action = self._action.get(CONF_ALIAS, "setting variables") - self._log("Executing step %s", self._script.last_action) + self._step_log("setting variables") self._variables = self._action[CONF_VARIABLES].async_render( self._hass, self._variables, render_as_defaults=False ) @@ -759,7 +767,7 @@ async def _async_stop_scripts_at_shutdown(hass, event): _VarsType = Union[Dict[str, Any], MappingProxyType] -def _referenced_extract_ids(data: Dict, key: str, found: Set[str]) -> None: +def _referenced_extract_ids(data: Dict[str, Any], key: str, found: Set[str]) -> None: """Extract referenced IDs.""" if not data: return @@ -1037,7 +1045,7 @@ class Script: raise async def _async_stop(self, update_state): - aws = [run.async_stop() for run in self._runs] + aws = [asyncio.create_task(run.async_stop()) for run in self._runs] if not aws: return await asyncio.wait(aws) @@ -1092,10 +1100,11 @@ class Script: await self._async_get_condition(config) for config in choice.get(CONF_CONDITIONS, []) ] + choice_name = choice.get(CONF_ALIAS, f"choice {idx}") sub_script = Script( self._hass, choice[CONF_SEQUENCE], - f"{self.name}: {step_name}: choice {idx}", + f"{self.name}: {step_name}: {choice_name}", self.domain, running_description=self.running_description, script_mode=SCRIPT_MODE_PARALLEL, diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 3140fc4dced..818263c9dd5 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -21,6 +21,7 @@ class ScriptVariables: run_variables: Optional[Mapping[str, Any]], *, render_as_defaults: bool = True, + limited: bool = False, ) -> Dict[str, Any]: """Render script variables. @@ -55,7 +56,9 @@ class ScriptVariables: if render_as_defaults and key in rendered_variables: continue - rendered_variables[key] = template.render_complex(value, rendered_variables) + rendered_variables[key] = template.render_complex( + value, rendered_variables, limited + ) return rendered_variables diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index b48ffb6e964..34befe9c37b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -116,6 +116,13 @@ class NumberSelector(Selector): ) +@SELECTORS.register("addon") +class AddonSelector(Selector): + """Selector of a add-on.""" + + CONFIG_SCHEMA = vol.Schema({}) + + @SELECTORS.register("boolean") class BooleanSelector(Selector): """Selector of a boolean value.""" @@ -176,3 +183,12 @@ class StringSelector(Selector): """Selector for a multi-line text string.""" CONFIG_SCHEMA = vol.Schema({vol.Optional("multiline", default=False): bool}) + + +@SELECTORS.register("select") +class SelectSelector(Selector): + """Selector for an single-choice input select.""" + + CONFIG_SCHEMA = vol.Schema( + {vol.Required("options"): vol.All([str], vol.Length(min=1))} + ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index c95f942c6dc..932384493f3 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,4 +1,6 @@ """Service calling related helpers.""" +from __future__ import annotations + import asyncio import dataclasses from functools import partial, wraps @@ -14,6 +16,7 @@ from typing import ( Optional, Set, Tuple, + TypedDict, Union, cast, ) @@ -25,7 +28,9 @@ from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + CONF_ENTITY_ID, CONF_SERVICE, + CONF_SERVICE_DATA, CONF_SERVICE_TEMPLATE, CONF_TARGET, ENTITY_MATCH_ALL, @@ -62,7 +67,6 @@ if TYPE_CHECKING: CONF_SERVICE_ENTITY_ID = "entity_id" -CONF_SERVICE_DATA = "data" CONF_SERVICE_DATA_TEMPLATE = "data_template" _LOGGER = logging.getLogger(__name__) @@ -70,6 +74,15 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +class ServiceParams(TypedDict): + """Type for service call parameters.""" + + domain: str + service: str + service_data: Dict[str, Any] + target: Optional[Dict] + + @dataclasses.dataclass class SelectedEntities: """Class to hold the selected entities.""" @@ -136,7 +149,7 @@ async def async_call_from_config( raise _LOGGER.error(ex) else: - await hass.services.async_call(*params, blocking, context) + await hass.services.async_call(**params, blocking=blocking, context=context) @ha.callback @@ -146,7 +159,7 @@ def async_prepare_call_from_config( config: ConfigType, variables: TemplateVarsType = None, validate_config: bool = False, -) -> Tuple[str, str, Dict[str, Any]]: +) -> ServiceParams: """Prepare to call a service based on a config hash.""" if validate_config: try: @@ -177,10 +190,24 @@ def async_prepare_call_from_config( domain, service = domain_service.split(".", 1) - service_data = {} - + target = {} if CONF_TARGET in config: - service_data.update(config[CONF_TARGET]) + conf = config.get(CONF_TARGET) + try: + template.attach(hass, conf) + target.update(template.render_complex(conf, variables)) + if CONF_ENTITY_ID in target: + target[CONF_ENTITY_ID] = cv.comp_entity_ids(target[CONF_ENTITY_ID]) + except TemplateError as ex: + raise HomeAssistantError( + f"Error rendering service target template: {ex}" + ) from ex + except vol.Invalid as ex: + raise HomeAssistantError( + f"Template rendered invalid entity IDs: {target[CONF_ENTITY_ID]}" + ) from ex + + service_data = {} for conf in [CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE]: if conf not in config: @@ -192,9 +219,17 @@ def async_prepare_call_from_config( raise HomeAssistantError(f"Error rendering data template: {ex}") from ex if CONF_SERVICE_ENTITY_ID in config: - service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] + if target: + target[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] + else: + target = {ATTR_ENTITY_ID: config[CONF_SERVICE_ENTITY_ID]} - return domain, service, service_data + return { + "domain": domain, + "service": service, + "service_data": service_data, + "target": target, + } @bind_hass @@ -213,10 +248,10 @@ def extract_entity_ids( @bind_hass async def async_extract_entities( hass: HomeAssistantType, - entities: Iterable["Entity"], + entities: Iterable[Entity], service_call: ha.ServiceCall, expand_group: bool = True, -) -> List["Entity"]: +) -> List[Entity]: """Extract a list of entity objects from a service call. Will convert group entity ids to the entity ids it represents. @@ -429,11 +464,17 @@ async def async_get_all_descriptions( # Don't warn for missing services, because it triggers false # positives for things like scripts, that register as a service - description = descriptions_cache[cache_key] = { + description = { + "name": yaml_description.get("name", ""), "description": yaml_description.get("description", ""), "fields": yaml_description.get("fields", {}), } + if "target" in yaml_description: + description["target"] = yaml_description["target"] + + descriptions_cache[cache_key] = description + descriptions[domain][service] = description return descriptions @@ -448,10 +489,14 @@ def async_set_service_schema( hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) description = { - "description": schema.get("description") or "", - "fields": schema.get("fields") or {}, + "name": schema.get("name", ""), + "description": schema.get("description", ""), + "fields": schema.get("fields", {}), } + if "target" in schema: + description["target"] = schema["target"] + hass.data[SERVICE_DESCRIPTION_CACHE][f"{domain}.{service}"] = description @@ -584,8 +629,10 @@ async def entity_service_call( done, pending = await asyncio.wait( [ - entity.async_request_call( - _handle_entity_call(hass, entity, func, data, call.context) + asyncio.create_task( + entity.async_request_call( + _handle_entity_call(hass, entity, func, data, call.context) + ) ) for entity in entities ] @@ -603,7 +650,7 @@ async def entity_service_call( # Context expires if the turn on commands took a long time. # Set context again so it's there when we update entity.async_set_context(call.context) - tasks.append(entity.async_update_ha_state(True)) + tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) if tasks: done, pending = await asyncio.wait(tasks) @@ -614,7 +661,7 @@ async def entity_service_call( async def _handle_entity_call( hass: HomeAssistantType, - entity: "Entity", + entity: Entity, func: Union[str, Callable[..., Any]], data: Union[Dict, ha.ServiceCall], context: ha.Context, @@ -669,10 +716,14 @@ def async_register_admin_service( @bind_hass @ha.callback -def verify_domain_control(hass: HomeAssistantType, domain: str) -> Callable: +def verify_domain_control( + hass: HomeAssistantType, domain: str +) -> Callable[[Callable[[ha.ServiceCall], Any]], Callable[[ha.ServiceCall], Any]]: """Ensure permission to access any entity under domain in service call.""" - def decorator(service_handler: Callable[[ha.ServiceCall], Any]) -> Callable: + def decorator( + service_handler: Callable[[ha.ServiceCall], Any] + ) -> Callable[[ha.ServiceCall], Any]: """Decorate.""" if not asyncio.iscoroutinefunction(service_handler): raise HomeAssistantError("Can only decorate async functions.") diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 694acfcf2bd..a7be57693ba 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -26,6 +26,8 @@ The following cases will never be passed to your function: - if either state is unknown/unavailable - state adding/removing """ +from __future__ import annotations + from types import MappingProxyType from typing import Any, Callable, Dict, Optional, Tuple, Union @@ -65,7 +67,7 @@ async def create_checker( hass: HomeAssistant, _domain: str, extra_significant_check: Optional[ExtraCheckTypeFunc] = None, -) -> "SignificantlyChangedChecker": +) -> SignificantlyChangedChecker: """Create a significantly changed checker for a domain.""" await _initialize(hass) return SignificantlyChangedChecker(hass, extra_significant_check) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index a969b2cad9a..2bc13fbdf44 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -202,7 +202,6 @@ class Store: async def _async_handle_write_data(self, *_args): """Handle writing the config.""" - async with self._write_lock: self._async_cleanup_delay_listener() self._async_cleanup_final_write_listener() diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 818010c3410..2b82e19b8ce 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -1,4 +1,6 @@ """Helpers for sun events.""" +from __future__ import annotations + import datetime from typing import TYPE_CHECKING, Optional, Union @@ -17,9 +19,8 @@ DATA_LOCATION_CACHE = "astral_location_cache" @callback @bind_hass -def get_astral_location(hass: HomeAssistantType) -> "astral.Location": +def get_astral_location(hass: HomeAssistantType) -> astral.Location: """Get an astral location for the current Home Assistant configuration.""" - from astral import Location # pylint: disable=import-outside-toplevel latitude = hass.config.latitude diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5f506c02eef..7377120af40 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1,4 +1,6 @@ """Template helper methods for rendering strings with Home Assistant data.""" +from __future__ import annotations + from ast import literal_eval import asyncio import base64 @@ -31,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import State, callback, split_entity_id, valid_entity_id from homeassistant.exceptions import TemplateError -from homeassistant.helpers import location as loc_helper +from homeassistant.helpers import entity_registry, location as loc_helper from homeassistant.helpers.typing import HomeAssistantType, TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import convert, dt as dt_util, location as loc_util @@ -46,6 +48,7 @@ DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" _RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" +_ENVIRONMENT_LIMITED = "template.environment_limited" _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 @@ -84,7 +87,9 @@ def attach(hass: HomeAssistantType, obj: Any) -> None: obj.hass = hass -def render_complex(value: Any, variables: TemplateVarsType = None) -> Any: +def render_complex( + value: Any, variables: TemplateVarsType = None, limited: bool = False +) -> Any: """Recursive template creator helper function.""" if isinstance(value, list): return [render_complex(item, variables) for item in value] @@ -94,7 +99,7 @@ def render_complex(value: Any, variables: TemplateVarsType = None) -> Any: for key, item in value.items() } if isinstance(value, Template): - return value.async_render(variables) + return value.async_render(variables, limited=limited) return value @@ -153,7 +158,7 @@ class TupleWrapper(tuple, ResultWrapper): def __new__( cls, value: tuple, *, render_result: Optional[str] = None - ) -> "TupleWrapper": + ) -> TupleWrapper: """Create a new tuple class.""" return super().__new__(cls, tuple(value)) @@ -279,6 +284,7 @@ class Template: "is_static", "_compiled_code", "_compiled", + "_limited", ) def __init__(self, template, hass=None): @@ -291,14 +297,16 @@ class Template: self._compiled: Optional[Template] = None self.hass = hass self.is_static = not is_template_string(template) + self._limited = None @property - def _env(self) -> "TemplateEnvironment": + def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV - ret: Optional[TemplateEnvironment] = self.hass.data.get(_ENVIRONMENT) + wanted_env = _ENVIRONMENT_LIMITED if self._limited else _ENVIRONMENT + ret: Optional[TemplateEnvironment] = self.hass.data.get(wanted_env) if ret is None: - ret = self.hass.data[_ENVIRONMENT] = TemplateEnvironment(self.hass) # type: ignore[no-untyped-call] + ret = self.hass.data[wanted_env] = TemplateEnvironment(self.hass, self._limited) # type: ignore[no-untyped-call] return ret def ensure_valid(self) -> None: @@ -315,9 +323,13 @@ class Template: self, variables: TemplateVarsType = None, parse_result: bool = True, + limited: bool = False, **kwargs: Any, ) -> Any: - """Render given template.""" + """Render given template. + + If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. + """ if self.is_static: if self.hass.config.legacy_templates or not parse_result: return self.template @@ -325,7 +337,7 @@ class Template: return run_callback_threadsafe( self.hass.loop, - partial(self.async_render, variables, parse_result, **kwargs), + partial(self.async_render, variables, parse_result, limited, **kwargs), ).result() @callback @@ -333,18 +345,21 @@ class Template: self, variables: TemplateVarsType = None, parse_result: bool = True, + limited: bool = False, **kwargs: Any, ) -> Any: """Render given template. This method must be run in the event loop. + + If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. """ if self.is_static: if self.hass.config.legacy_templates or not parse_result: return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled() + compiled = self._compiled or self._ensure_compiled(limited) if variables is not None: kwargs.update(variables) @@ -519,12 +534,16 @@ class Template: ) return value if error_value is _SENTINEL else error_value - def _ensure_compiled(self) -> "Template": + def _ensure_compiled(self, limited: bool = False) -> Template: """Bind a template to a specific hass instance.""" self.ensure_valid() assert self.hass is not None, "hass variable not set on template" + assert ( + self._limited is None or self._limited == limited + ), "can't change between limited and non limited template" + self._limited = limited env = self._env self._compiled = cast( @@ -850,6 +869,13 @@ def expand(hass: HomeAssistantType, *args: Any) -> Iterable[State]: return sorted(found.values(), key=lambda a: a.entity_id) +def device_entities(hass: HomeAssistantType, device_id: str) -> Iterable[str]: + """Get entity ids for entities tied to a device.""" + entity_reg = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_device(entity_reg, device_id) + return [entry.entity_id for entry in entries] + + def closest(hass, *args): """Find closest entity. @@ -1277,7 +1303,6 @@ def relative_time(value): If the input are not a datetime object the input will be returned unmodified. """ - if not isinstance(value, datetime): return value if not value.tzinfo: @@ -1295,7 +1320,7 @@ def urlencode(value): class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass): + def __init__(self, hass, limited=False): """Initialise template environment.""" super().__init__() self.hass = hass @@ -1367,6 +1392,38 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return contextfunction(wrapper) + self.globals["device_entities"] = hassfunction(device_entities) + self.filters["device_entities"] = contextfilter(self.globals["device_entities"]) + + if limited: + # Only device_entities is available to limited templates, mark other + # functions and filters as unsupported. + def unsupported(name): + def warn_unsupported(*args, **kwargs): + raise TemplateError( + f"Use of '{name}' is not supported in limited templates" + ) + + return warn_unsupported + + hass_globals = [ + "closest", + "distance", + "expand", + "is_state", + "is_state_attr", + "state_attr", + "states", + "utcnow", + "now", + ] + hass_filters = ["closest", "expand"] + for glob in hass_globals: + self.globals[glob] = unsupported(glob) + for filt in hass_filters: + self.filters[filt] = unsupported(filt) + return + self.globals["expand"] = hassfunction(expand) self.filters["expand"] = contextfilter(self.globals["expand"]) self.globals["closest"] = hassfunction(closest) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 2c7275a9cc3..58ac71a515e 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.loader import IntegrationNotFound, async_get_integration @@ -79,7 +80,9 @@ async def async_initialize_triggers( removes = [] for result in attach_results: - if isinstance(result, Exception): + if isinstance(result, HomeAssistantError): + log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for") + elif isinstance(result, Exception): log_cb(logging.ERROR, "Error setting up trigger", exc_info=result) elif result is None: log_cb( diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 8df2c57b1e7..8ba355e6489 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -3,7 +3,7 @@ import asyncio from datetime import datetime, timedelta import logging from time import monotonic -from typing import Awaitable, Callable, Generic, List, Optional, TypeVar +from typing import Any, Awaitable, Callable, Generic, List, Optional, TypeVar import urllib.error import aiohttp @@ -21,6 +21,8 @@ REQUEST_REFRESH_DEFAULT_IMMEDIATE = True T = TypeVar("T") +# mypy: disallow-any-generics + class UpdateFailed(Exception): """Raised when an update has failed.""" @@ -231,7 +233,7 @@ class DataUpdateCoordinator(Generic[T]): class CoordinatorEntity(entity.Entity): """A class for entities using DataUpdateCoordinator.""" - def __init__(self, coordinator: DataUpdateCoordinator) -> None: + def __init__(self, coordinator: DataUpdateCoordinator[Any]) -> None: """Create the entity with a DataUpdateCoordinator.""" self.coordinator = coordinator @@ -262,7 +264,6 @@ class CoordinatorEntity(entity.Entity): Only used by the generic entity update service. """ - # Ignore manual update requests if the entity is disabled if not self.enabled: return diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 215f552a908..2ae279da79e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -4,6 +4,8 @@ The methods for loading Home Assistant integrations. This module has quite some complex parts. I have tried to add as much documentation as possible to keep it understandable. """ +from __future__ import annotations + import asyncio import functools as ft import importlib @@ -26,6 +28,8 @@ from typing import ( cast, ) +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy + from homeassistant.generated.dhcp import DHCP from homeassistant.generated.mqtt import MQTT from homeassistant.generated.ssdp import SSDP @@ -49,10 +53,22 @@ DATA_CUSTOM_COMPONENTS = "custom_components" PACKAGE_CUSTOM_COMPONENTS = "custom_components" PACKAGE_BUILTIN = "homeassistant.components" CUSTOM_WARNING = ( - "You are using a custom integration for %s which has not " + "You are using a custom integration %s which has not " "been tested by Home Assistant. This component might " "cause stability problems, be sure to disable it if you " - "experience issues with Home Assistant." + "experience issues with Home Assistant" +) +CUSTOM_WARNING_VERSION_MISSING = ( + "No 'version' key in the manifest file for " + "custom integration '%s'. This will not be " + "allowed in a future version of Home " + "Assistant. Please report this to the " + "maintainer of '%s'" +) +CUSTOM_WARNING_VERSION_TYPE = ( + "'%s' is not a valid version for " + "custom integration '%s'. " + "Please report this to the maintainer of '%s'" ) _UNDEF = object() # Internal; not helpers.typing.UNDEFINED due to circular dependency @@ -83,6 +99,7 @@ class Manifest(TypedDict, total=False): dhcp: List[Dict[str, str]] homekit: Dict[str, List[str]] is_built_in: bool + version: str codeowners: List[str] @@ -99,7 +116,7 @@ def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest: async def _async_get_custom_components( hass: "HomeAssistant", -) -> Dict[str, "Integration"]: +) -> Dict[str, Integration]: """Return list of custom integrations.""" if hass.config.safe_mode: return {} @@ -140,7 +157,7 @@ async def _async_get_custom_components( async def async_get_custom_components( hass: "HomeAssistant", -) -> Dict[str, "Integration"]: +) -> Dict[str, Integration]: """Return cached list of custom integrations.""" reg_or_evt = hass.data.get(DATA_CUSTOM_COMPONENTS) @@ -160,7 +177,7 @@ async def async_get_custom_components( return cast(Dict[str, "Integration"], reg_or_evt) -async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]: +async def async_get_config_flows(hass: HomeAssistant) -> Set[str]: """Return cached list of config flows.""" # pylint: disable=import-outside-toplevel from homeassistant.generated.config_flows import FLOWS @@ -180,7 +197,7 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]: return flows -async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List[Dict[str, str]]]: +async def async_get_zeroconf(hass: HomeAssistant) -> Dict[str, List[Dict[str, str]]]: """Return cached list of zeroconf types.""" zeroconf: Dict[str, List[Dict[str, str]]] = ZEROCONF.copy() @@ -203,7 +220,7 @@ async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List[Dict[str, return zeroconf -async def async_get_dhcp(hass: "HomeAssistant") -> List[Dict[str, str]]: +async def async_get_dhcp(hass: HomeAssistant) -> List[Dict[str, str]]: """Return cached list of dhcp types.""" dhcp: List[Dict[str, str]] = DHCP.copy() @@ -217,7 +234,7 @@ async def async_get_dhcp(hass: "HomeAssistant") -> List[Dict[str, str]]: return dhcp -async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]: +async def async_get_homekit(hass: HomeAssistant) -> Dict[str, str]: """Return cached list of homekit models.""" homekit: Dict[str, str] = HOMEKIT.copy() @@ -236,7 +253,7 @@ async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]: return homekit -async def async_get_ssdp(hass: "HomeAssistant") -> Dict[str, List[Dict[str, str]]]: +async def async_get_ssdp(hass: HomeAssistant) -> Dict[str, List[Dict[str, str]]]: """Return cached list of ssdp mappings.""" ssdp: Dict[str, List[Dict[str, str]]] = SSDP.copy() @@ -251,7 +268,7 @@ async def async_get_ssdp(hass: "HomeAssistant") -> Dict[str, List[Dict[str, str] return ssdp -async def async_get_mqtt(hass: "HomeAssistant") -> Dict[str, List[str]]: +async def async_get_mqtt(hass: HomeAssistant) -> Dict[str, List[str]]: """Return cached list of MQTT mappings.""" mqtt: Dict[str, List[str]] = MQTT.copy() @@ -272,7 +289,7 @@ class Integration: @classmethod def resolve_from_root( cls, hass: "HomeAssistant", root_module: ModuleType, domain: str - ) -> "Optional[Integration]": + ) -> Optional[Integration]: """Resolve an integration from a root module.""" for base in root_module.__path__: # type: ignore manifest_path = pathlib.Path(base) / domain / "manifest.json" @@ -297,7 +314,7 @@ class Integration: @classmethod def resolve_legacy( cls, hass: "HomeAssistant", domain: str - ) -> "Optional[Integration]": + ) -> Optional[Integration]: """Resolve legacy component. Will create a stub manifest. @@ -417,6 +434,13 @@ class Integration: """Test if package is a built-in integration.""" return self.pkg_path.startswith(PACKAGE_BUILTIN) + @property + def version(self) -> Optional[AwesomeVersion]: + """Return the version of the integration.""" + if "version" not in self.manifest: + return None + return AwesomeVersion(self.manifest["version"]) + @property def all_dependencies(self) -> Set[str]: """Return all dependencies including sub-dependencies.""" @@ -513,7 +537,7 @@ async def async_get_integration(hass: "HomeAssistant", domain: str) -> Integrati # components to find the integration. integration = (await async_get_custom_components(hass)).get(domain) if integration is not None: - _LOGGER.warning(CUSTOM_WARNING, domain) + custom_integration_warning(integration) cache[domain] = integration event.set() return integration @@ -531,6 +555,7 @@ async def async_get_integration(hass: "HomeAssistant", domain: str) -> Integrati integration = Integration.resolve_legacy(hass, domain) if integration is not None: + custom_integration_warning(integration) cache[domain] = integration else: # Remove event from cache. @@ -605,9 +630,6 @@ def _load_file( cache[comp_or_platform] = module - if module.__name__.startswith(PACKAGE_CUSTOM_COMPONENTS): - _LOGGER.warning(CUSTOM_WARNING, comp_or_platform) - return module except ImportError as err: @@ -651,7 +673,7 @@ class ModuleWrapper: class Components: """Helper to load components.""" - def __init__(self, hass: "HomeAssistant") -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Components class.""" self._hass = hass @@ -677,7 +699,7 @@ class Components: class Helpers: """Helper to load helpers.""" - def __init__(self, hass: "HomeAssistant") -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Helpers class.""" self._hass = hass @@ -738,7 +760,7 @@ async def _async_component_dependencies( return loaded -def _async_mount_config_dir(hass: "HomeAssistant") -> bool: +def _async_mount_config_dir(hass: HomeAssistant) -> bool: """Mount config dir in order to load custom_component. Async friendly but not a coroutine. @@ -751,8 +773,40 @@ def _async_mount_config_dir(hass: "HomeAssistant") -> bool: return True -def _lookup_path(hass: "HomeAssistant") -> List[str]: +def _lookup_path(hass: HomeAssistant) -> List[str]: """Return the lookup paths for legacy lookups.""" if hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] + + +def validate_custom_integration_version(version: str) -> bool: + """Validate the version of custom integrations.""" + return AwesomeVersion(version).strategy in ( + AwesomeVersionStrategy.CALVER, + AwesomeVersionStrategy.SEMVER, + AwesomeVersionStrategy.SIMPLEVER, + AwesomeVersionStrategy.BUILDVER, + AwesomeVersionStrategy.PEP440, + ) + + +def custom_integration_warning(integration: Integration) -> None: + """Create logs for custom integrations.""" + if not integration.pkg_path.startswith(PACKAGE_CUSTOM_COMPONENTS): + return None + + _LOGGER.warning(CUSTOM_WARNING, integration.domain) + + if integration.manifest.get("version") is None: + _LOGGER.warning( + CUSTOM_WARNING_VERSION_MISSING, integration.domain, integration.domain + ) + else: + if not validate_custom_integration_version(integration.manifest["version"]): + _LOGGER.warning( + CUSTOM_WARNING_VERSION_TYPE, + integration.manifest["version"], + integration.domain, + integration.domain, + ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index adf5cd6088a..752a3755169 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,33 +1,34 @@ PyJWT==1.7.1 PyNaCl==1.3.0 -aiohttp==3.7.3 +aiohttp==3.7.4 aiohttp_cors==0.7.0 astral==1.10.1 +async-upnp-client==0.14.13 async_timeout==3.0.1 attrs==19.3.0 -awesomeversion==21.2.2 +awesomeversion==21.2.3 bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 -cryptography==3.2 +cryptography==3.3.2 defusedxml==0.6.0 distro==1.5.0 -emoji==0.5.4 +emoji==1.2.0 hass-nabucasa==0.41.0 -home-assistant-frontend==20210127.7 +home-assistant-frontend==20210302.3 httpx==0.16.1 -jinja2>=2.11.2 +jinja2>=2.11.3 netdisco==2.8.2 paho-mqtt==1.5.1 -pillow==8.1.0 +pillow==8.1.1 pip>=8.0.3,<20.3 python-slugify==4.0.1 -pytz>=2020.5 +pytz>=2021.1 pyyaml==5.4.1 requests==2.25.1 ruamel.yaml==0.15.100 scapy==2.4.4 -sqlalchemy==1.3.22 +sqlalchemy==1.3.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 @@ -45,8 +46,9 @@ h11>=0.12.0 # https://github.com/encode/httpcore/issues/239 httpcore>=0.12.3 -# Constrain httplib2 to protect against CVE-2020-11078 -httplib2>=0.18.0 +# Constrain httplib2 to protect against GHSA-93xj-8mrv-444m +# https://github.com/advisories/GHSA-93xj-8mrv-444m +httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 48e6d7d5302..3f590362504 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -62,7 +62,7 @@ async def fire_events(hass): """Fire a million events.""" count = 0 event_name = "benchmark_event" - event = asyncio.Event() + events_to_fire = 10 ** 6 @core.callback def listener(_): @@ -70,17 +70,48 @@ async def fire_events(hass): nonlocal count count += 1 - if count == 10 ** 6: - event.set() - hass.bus.async_listen(event_name, listener) - for _ in range(10 ** 6): + for _ in range(events_to_fire): hass.bus.async_fire(event_name) start = timer() - await event.wait() + await hass.async_block_till_done() + + assert count == events_to_fire + + return timer() - start + + +@benchmark +async def fire_events_with_filter(hass): + """Fire a million events with a filter that rejects them.""" + count = 0 + event_name = "benchmark_event" + events_to_fire = 10 ** 6 + + @core.callback + def event_filter(event): + """Filter event.""" + return False + + @core.callback + def listener(_): + """Handle event.""" + nonlocal count + count += 1 + + hass.bus.async_listen(event_name, listener, event_filter=event_filter) + + for _ in range(events_to_fire): + hass.bus.async_fire(event_name) + + start = timer() + + await hass.async_block_till_done() + + assert count == 0 return timer() - start @@ -154,7 +185,7 @@ async def state_changed_event_helper(hass): """Run a million events through state changed event helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" - event = asyncio.Event() + events_to_fire = 10 ** 6 @core.callback def listener(*args): @@ -162,9 +193,6 @@ async def state_changed_event_helper(hass): nonlocal count count += 1 - if count == 10 ** 6: - event.set() - hass.helpers.event.async_track_state_change_event( [f"{entity_id}{idx}" for idx in range(1000)], listener ) @@ -175,12 +203,49 @@ async def state_changed_event_helper(hass): "new_state": core.State(entity_id, "on"), } - for _ in range(10 ** 6): + for _ in range(events_to_fire): hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) start = timer() - await event.wait() + await hass.async_block_till_done() + + assert count == events_to_fire + + return timer() - start + + +@benchmark +async def state_changed_event_filter_helper(hass): + """Run a million events through state changed event helper with 1000 entities that all get filtered.""" + count = 0 + entity_id = "light.kitchen" + events_to_fire = 10 ** 6 + + @core.callback + def listener(*args): + """Handle event.""" + nonlocal count + count += 1 + + hass.helpers.event.async_track_state_change_event( + [f"{entity_id}{idx}" for idx in range(1000)], listener + ) + + event_data = { + "entity_id": "switch.no_listeners", + "old_state": core.State(entity_id, "off"), + "new_state": core.State(entity_id, "on"), + } + + for _ in range(events_to_fire): + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + + start = timer() + + await hass.async_block_till_done() + + assert count == 0 return timer() - start diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 07a6a54e402..f75594a546e 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -17,7 +17,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==4.6.2",) +REQUIREMENTS = ("colorlog==4.7.2",) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access @@ -141,12 +141,7 @@ def run(script_args: List) -> int: if sval is None: print(" -", skey + ":", color("red", "not found")) continue - print( - " -", - skey + ":", - sval, - color("cyan", "[from:", flatsecret.get(skey, "keyring") + "]"), - ) + print(" -", skey + ":", sval) return len(res["except"]) diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py deleted file mode 100644 index 99227d81b66..00000000000 --- a/homeassistant/scripts/credstash.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Script to get, put and delete secrets stored in credstash.""" -import argparse -import getpass - -from homeassistant.util.yaml import _SECRET_NAMESPACE - -# mypy: allow-untyped-defs - -REQUIREMENTS = ["credstash==1.15.0"] - - -def run(args): - """Handle credstash script.""" - parser = argparse.ArgumentParser( - description=( - "Modify Home Assistant secrets in credstash." - "Use the secrets in configuration files with: " - "!secret " - ) - ) - parser.add_argument("--script", choices=["credstash"]) - parser.add_argument( - "action", - choices=["get", "put", "del", "list"], - help="Get, put or delete a secret, or list all available secrets", - ) - parser.add_argument("name", help="Name of the secret", nargs="?", default=None) - parser.add_argument( - "value", help="The value to save when putting a secret", nargs="?", default=None - ) - - # pylint: disable=import-error, no-member, import-outside-toplevel - import credstash - - args = parser.parse_args(args) - table = _SECRET_NAMESPACE - - try: - credstash.listSecrets(table=table) - except Exception: # pylint: disable=broad-except - credstash.createDdbTable(table=table) - - if args.action == "list": - secrets = [i["name"] for i in credstash.listSecrets(table=table)] - deduped_secrets = sorted(set(secrets)) - - print("Saved secrets:") - for secret in deduped_secrets: - print(secret) - return 0 - - if args.name is None: - parser.print_help() - return 1 - - if args.action == "put": - if args.value: - the_secret = args.value - else: - the_secret = getpass.getpass(f"Please enter the secret for {args.name}: ") - current_version = credstash.getHighestVersion(args.name, table=table) - credstash.putSecret( - args.name, the_secret, version=int(current_version) + 1, table=table - ) - print(f"Secret {args.name} put successfully") - elif args.action == "get": - the_secret = credstash.getSecret(args.name, table=table) - if the_secret is None: - print(f"Secret {args.name} not found") - else: - print(f"Secret {args.name}={the_secret}") - elif args.action == "del": - credstash.deleteSecrets(args.name, table=table) - print(f"Deleted secret {args.name}") diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py deleted file mode 100644 index 0166d41ce0c..00000000000 --- a/homeassistant/scripts/keyring.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Script to get, set and delete secrets stored in the keyring.""" -import argparse -import getpass -import os - -from homeassistant.util.yaml import _SECRET_NAMESPACE - -# mypy: allow-untyped-defs -REQUIREMENTS = ["keyring==21.2.0", "keyrings.alt==3.4.0"] - - -def run(args): - """Handle keyring script.""" - parser = argparse.ArgumentParser( - description=( - "Modify Home Assistant secrets in the default keyring. " - "Use the secrets in configuration files with: " - "!secret " - ) - ) - parser.add_argument("--script", choices=["keyring"]) - parser.add_argument( - "action", - choices=["get", "set", "del", "info"], - help="Get, set or delete a secret", - ) - parser.add_argument("name", help="Name of the secret", nargs="?", default=None) - - import keyring # pylint: disable=import-outside-toplevel - - # pylint: disable=import-outside-toplevel - from keyring.util import platform_ as platform - - args = parser.parse_args(args) - - if args.action == "info": - keyr = keyring.get_keyring() - print("Keyring version {}\n".format(REQUIREMENTS[0].split("==")[1])) - print(f"Active keyring : {keyr.__module__}") - config_name = os.path.join(platform.config_root(), "keyringrc.cfg") - print(f"Config location : {config_name}") - print(f"Data location : {platform.data_root()}\n") - elif args.name is None: - parser.print_help() - return 1 - - if args.action == "set": - entered_secret = getpass.getpass(f"Please enter the secret for {args.name}: ") - keyring.set_password(_SECRET_NAMESPACE, args.name, entered_secret) - print(f"Secret {args.name} set successfully") - elif args.action == "get": - the_secret = keyring.get_password(_SECRET_NAMESPACE, args.name) - if the_secret is None: - print(f"Secret {args.name} not found") - else: - print(f"Secret {args.name}={the_secret}") - elif args.action == "del": - try: - keyring.delete_password(_SECRET_NAMESPACE, args.name) - print(f"Deleted secret {args.name}") - except keyring.errors.PasswordDeleteError: - print(f"Secret {args.name} not found") diff --git a/homeassistant/strings.json b/homeassistant/strings.json index e2a85637fbb..b8e7dee2996 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -71,7 +71,7 @@ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", "reauth_successful": "Re-authentication was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize url." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL." } } } diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 36cdc0f25e2..f2c761282bc 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -48,7 +48,7 @@ class MockRequest: self.mock_source = mock_source @property - def query(self) -> "MultiDict[str]": + def query(self) -> MultiDict[str]: """Return a dictionary with the query variables.""" return MultiDict(parse_qsl(self.query_string, keep_blank_values=True)) @@ -66,7 +66,7 @@ class MockRequest: """Return the body as JSON.""" return json.loads(self._text) - async def post(self) -> "MultiDict[str]": + async def post(self) -> MultiDict[str]: """Return POST parameters.""" return MultiDict(parse_qsl(self._text, keep_blank_values=True)) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 1e782f0c0f9..9a5fbdb180f 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -160,6 +160,8 @@ COLORS = { "whitesmoke": (245, 245, 245), "yellow": (255, 255, 0), "yellowgreen": (154, 205, 50), + # And... + "homeassistant": (3, 169, 244), } diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index feef339a200..423685fe9d4 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -6,7 +6,7 @@ import logging import logging.handlers import queue import traceback -from typing import Any, Callable, Coroutine +from typing import Any, Awaitable, Callable, Coroutine, Union, cast, overload from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import HomeAssistant, callback @@ -30,13 +30,6 @@ class HideSensitiveDataFilter(logging.Filter): class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" - def emit(self, record: logging.LogRecord) -> None: - """Emit a log record.""" - try: - self.enqueue(record) - except Exception: # pylint: disable=broad-except - self.handleError(record) - def handle(self, record: logging.LogRecord) -> Any: """ Conditionally emit the specified logging record. @@ -106,9 +99,23 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) +@overload +def catch_log_exception( # type: ignore + func: Callable[..., Awaitable[Any]], format_err: Callable[..., Any], *args: Any +) -> Callable[..., Awaitable[None]]: + """Overload for Callables that return an Awaitable.""" + + +@overload def catch_log_exception( func: Callable[..., Any], format_err: Callable[..., Any], *args: Any -) -> Callable[[], None]: +) -> Callable[..., None]: + """Overload for Callables that return Any.""" + + +def catch_log_exception( + func: Callable[..., Any], format_err: Callable[..., Any], *args: Any +) -> Union[Callable[..., None], Callable[..., Awaitable[None]]]: """Decorate a callback to catch and log exceptions.""" # Check for partials to properly determine if coroutine function @@ -116,14 +123,15 @@ def catch_log_exception( while isinstance(check_func, partial): check_func = check_func.func - wrapper_func = None + wrapper_func: Union[Callable[..., None], Callable[..., Awaitable[None]]] if asyncio.iscoroutinefunction(check_func): + async_func = cast(Callable[..., Awaitable[None]], func) - @wraps(func) + @wraps(async_func) async def async_wrapper(*args: Any) -> None: """Catch and log exception.""" try: - await func(*args) + await async_func(*args) except Exception: # pylint: disable=broad-except log_exception(format_err, *args) diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py new file mode 100644 index 00000000000..949af7dbb32 --- /dev/null +++ b/homeassistant/util/percentage.py @@ -0,0 +1,97 @@ +"""Percentage util functions.""" + +from typing import List, Tuple + + +def ordered_list_item_to_percentage(ordered_list: List[str], item: str) -> int: + """Determine the percentage of an item in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when when the item is passed + in: + + low: 25 + medium: 50 + high: 75 + very_high: 100 + + """ + if item not in ordered_list: + raise ValueError(f'The item "{item}"" is not in "{ordered_list}"') + + list_len = len(ordered_list) + list_position = ordered_list.index(item) + 1 + return (list_position * 100) // list_len + + +def percentage_to_ordered_list_item(ordered_list: List[str], percentage: int) -> str: + """Find the item that most closely matches the percentage in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when when the item is passed + in: + + 1-25: low + 26-50: medium + 51-75: high + 76-100: very_high + """ + list_len = len(ordered_list) + if not list_len: + raise ValueError("The ordered list is empty") + + for offset, speed in enumerate(ordered_list): + list_position = offset + 1 + upper_bound = (list_position * 100) // list_len + if percentage <= upper_bound: + return speed + + return ordered_list[-1] + + +def ranged_value_to_percentage( + low_high_range: Tuple[float, float], value: float +) -> int: + """Given a range of low and high values convert a single value to a percentage. + + When using this utility for fan speeds, do not include 0 if it is off + + Given a low value of 1 and a high value of 255 this function + will return: + + (1,255), 255: 100 + (1,255), 127: 50 + (1,255), 10: 4 + """ + return int((value * 100) // states_in_range(low_high_range)) + + +def percentage_to_ranged_value( + low_high_range: Tuple[float, float], percentage: int +) -> float: + """Given a range of low and high values convert a percentage to a single value. + + When using this utility for fan speeds, do not include 0 if it is off + + Given a low value of 1 and a high value of 255 this function + will return: + + (1,255), 100: 255 + (1,255), 50: 127.5 + (1,255), 4: 10.2 + """ + return states_in_range(low_high_range) * percentage / 100 + + +def states_in_range(low_high_range: Tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: Tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index ac4ac2f9a16..a152086ea82 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,5 +1,5 @@ """YAML utility functions.""" -from .const import _SECRET_NAMESPACE, SECRET_YAML +from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute from .loader import clear_secret_cache, load_yaml, parse_yaml, secret_yaml @@ -7,7 +7,6 @@ from .objects import Input __all__ = [ "SECRET_YAML", - "_SECRET_NAMESPACE", "Input", "dump", "save_yaml", diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py index bf1615edb93..9d930b50fd6 100644 --- a/homeassistant/util/yaml/const.py +++ b/homeassistant/util/yaml/const.py @@ -1,4 +1,2 @@ """Constants.""" SECRET_YAML = "secrets.yaml" - -_SECRET_NAMESPACE = "homeassistant" diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 746806f527d..7d713c9f0c0 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -10,20 +10,9 @@ import yaml from homeassistant.exceptions import HomeAssistantError -from .const import _SECRET_NAMESPACE, SECRET_YAML +from .const import SECRET_YAML from .objects import Input, NodeListClass, NodeStrClass -try: - import keyring -except ImportError: - keyring = None - -try: - import credstash -except ImportError: - credstash = None - - # mypy: allow-untyped-calls, no-warn-return-any JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name @@ -32,9 +21,6 @@ DICT_T = TypeVar("DICT_T", bound=Dict) # pylint: disable=invalid-name _LOGGER = logging.getLogger(__name__) __SECRET_CACHE: Dict[str, JSON_TYPE] = {} -CREDSTASH_WARN = False -KEYRING_WARN = False - def clear_secret_cache() -> None: """Clear the secret cache. @@ -275,6 +261,11 @@ def _load_secret_yaml(secret_path: str) -> JSON_TYPE: def secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" + if os.path.basename(loader.name) == SECRET_YAML: + _LOGGER.error("secrets.yaml: attempt to load secret from within secrets file") + raise HomeAssistantError( + "secrets.yaml: attempt to load secret from within secrets file" + ) secret_path = os.path.dirname(loader.name) while True: secrets = _load_secret_yaml(secret_path) @@ -294,43 +285,6 @@ def secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: if not os.path.exists(secret_path) or len(secret_path) < 5: break # Somehow we got past the .homeassistant config folder - if keyring: - # do some keyring stuff - pwd = keyring.get_password(_SECRET_NAMESPACE, node.value) - if pwd: - global KEYRING_WARN # pylint: disable=global-statement - - if not KEYRING_WARN: - KEYRING_WARN = True - _LOGGER.warning( - "Keyring is deprecated and will be removed in March 2021." - ) - - _LOGGER.debug("Secret %s retrieved from keyring", node.value) - return pwd - - global credstash # pylint: disable=invalid-name, global-statement - - if credstash: - # pylint: disable=no-member - try: - pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE) - if pwd: - global CREDSTASH_WARN # pylint: disable=global-statement - - if not CREDSTASH_WARN: - CREDSTASH_WARN = True - _LOGGER.warning( - "Credstash is deprecated and will be removed in March 2021." - ) - _LOGGER.debug("Secret %s retrieved from credstash", node.value) - return pwd - except credstash.ItemNotFound: - pass - except Exception: # pylint: disable=broad-except - # Catch if package installed and no config - credstash = None - raise HomeAssistantError(f"Secret {node.value} not defined") diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 0e46820e0db..2d318a9def0 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -1,4 +1,6 @@ """Custom yaml object types.""" +from __future__ import annotations + from dataclasses import dataclass import yaml @@ -19,6 +21,6 @@ class Input: name: str @classmethod - def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> "Input": + def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> Input: """Create a new placeholder from a node.""" return cls(node.value) diff --git a/machine/generic-x86-64 b/machine/generic-x86-64 new file mode 100644 index 00000000000..4c83228387d --- /dev/null +++ b/machine/generic-x86-64 @@ -0,0 +1,34 @@ +ARG BUILD_VERSION +FROM homeassistant/amd64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + libva-intel-driver \ + usbutils + +## +# Build libcec for HDMI-CEC +ARG LIBCEC_VERSION=6.0.2 +RUN apk add --no-cache \ + eudev-libs \ + p8-platform \ + && apk add --no-cache --virtual .build-dependencies \ + build-base \ + cmake \ + eudev-dev \ + swig \ + p8-platform-dev \ + linux-headers \ + && git clone --depth 1 -b libcec-${LIBCEC_VERSION} https://github.com/Pulse-Eight/libcec /usr/src/libcec \ + && cd /usr/src/libcec \ + && mkdir -p /usr/src/libcec/build \ + && cd /usr/src/libcec/build \ + && cmake -DCMAKE_INSTALL_PREFIX:PATH=/usr/local \ + -DPYTHON_LIBRARY="/usr/local/lib/libpython3.8.so" \ + -DPYTHON_INCLUDE_DIR="/usr/local/include/python3.8" \ + -DHAVE_LINUX_API=1 \ + .. \ + && make -j$(nproc) \ + && make install \ + && echo "cec" > "/usr/local/lib/python3.8/site-packages/cec.pth" \ + && apk del .build-dependencies \ + && rm -rf /usr/src/libcec* diff --git a/machine/intel-nuc b/machine/intel-nuc index 4c83228387d..b5538b8ccad 100644 --- a/machine/intel-nuc +++ b/machine/intel-nuc @@ -1,6 +1,9 @@ ARG BUILD_VERSION FROM homeassistant/amd64-homeassistant:$BUILD_VERSION +# NOTE: intel-nuc will be replaced by generic-x86-64. Make sure to apply +# changes in generic-x86-64 as well. + RUN apk --no-cache add \ libva-intel-driver \ usbutils diff --git a/requirements.txt b/requirements.txt index 4a983b0ba70..0a5b754dbfc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,21 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.7.3 +aiohttp==3.7.4 astral==1.10.1 async_timeout==3.0.1 attrs==19.3.0 -awesomeversion==21.2.2 +awesomeversion==21.2.3 bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 httpx==0.16.1 -jinja2>=2.11.2 +jinja2>=2.11.3 PyJWT==1.7.1 -cryptography==3.2 +cryptography==3.3.2 pip>=8.0.3,<20.3 python-slugify==4.0.1 -pytz>=2020.5 +pytz>=2021.1 pyyaml==5.4.1 requests==2.25.1 ruamel.yaml==0.15.100 diff --git a/requirements_all.txt b/requirements_all.txt index 83c93c0aa4d..ffbd439ee2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,8 +1,8 @@ # Home Assistant Core, full dependency set -r requirements.txt -# homeassistant.components.nuimo_controller -# --only-binary=all nuimo==0.1.0 +# homeassistant.components.aemet +AEMET-OpenData==0.1.8 # homeassistant.components.dht # Adafruit-DHT==1.4.0 @@ -17,7 +17,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.1.0 +HAP-python==3.3.2 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -49,7 +49,7 @@ PyNaCl==1.3.0 PyQRCode==1.2.1 # homeassistant.components.rmvtransport -PyRMVtransport==0.2.10 +PyRMVtransport==0.3.1 # homeassistant.components.telegram_bot PySocks==1.7.1 @@ -81,7 +81,7 @@ RtmAPI==0.7.2 TravisPy==0.3.5 # homeassistant.components.twitter -TwitterAPI==2.6.3 +TwitterAPI==2.6.6 # homeassistant.components.tof # VL53L1X2==0.1.5 @@ -96,7 +96,7 @@ WazeRouteCalculator==0.12 abodepy==1.2.0 # homeassistant.components.accuweather -accuweather==0.0.11 +accuweather==0.1.0 # homeassistant.components.bmp280 adafruit-circuitpython-bmp280==3.1.1 @@ -108,7 +108,7 @@ adafruit-circuitpython-mcp230xx==2.2.2 adb-shell[async]==0.2.1 # homeassistant.components.alarmdecoder -adext==0.3 +adext==0.4.1 # homeassistant.components.adguard adguardhome==0.4.2 @@ -154,14 +154,11 @@ aiodns==2.0.0 aioeafm==0.1.2 # homeassistant.components.esphome -aioesphomeapi==2.6.4 +aioesphomeapi==2.6.5 # homeassistant.components.flo aioflo==0.4.1 -# homeassistant.components.freebox -aiofreepybox==0.0.8 - # homeassistant.components.yi aioftp==0.12.0 @@ -169,7 +166,7 @@ aioftp==0.12.0 aioguardian==1.0.4 # homeassistant.components.harmony -aioharmony==0.2.6 +aioharmony==0.2.7 # homeassistant.components.homekit_controller aiohomekit==0.2.60 @@ -199,6 +196,9 @@ aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta aiolip==1.1.4 +# homeassistant.components.lyric +aiolyric==1.0.5 + # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -221,7 +221,7 @@ aiopylgtv==0.3.3 aiorecollect==1.0.1 # homeassistant.components.shelly -aioshelly==0.5.1.beta0 +aioshelly==0.6.1 # homeassistant.components.switcher_kis aioswitcher==1.2.1 @@ -284,6 +284,7 @@ asmog==0.0.6 asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr +# homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 @@ -300,7 +301,7 @@ auroranoaa==0.0.2 aurorapy==0.2.6 # homeassistant.components.stream -av==8.0.2 +av==8.0.3 # homeassistant.components.avea # avea==1.5.1 @@ -333,16 +334,16 @@ batinfo==0.4.2 # beacontools[scan]==1.2.3 # homeassistant.components.scrape -beautifulsoup4==4.9.1 +beautifulsoup4==4.9.3 # homeassistant.components.beewi_smartclim # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.21.0 +bellows==0.22.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.14 +bimmer_connected==0.7.15 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -351,7 +352,7 @@ bizkaibus==0.1.1 blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.16.4 +blinkpy==0.17.0 # homeassistant.components.blinksticklight blinkstick==1.1.8 @@ -370,7 +371,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.8 +bond-api==0.1.11 # homeassistant.components.amazon_polly # homeassistant.components.route53 @@ -383,7 +384,7 @@ bravia-tv==1.0.8 broadlink==0.16.0 # homeassistant.components.brother -brother==0.1.21 +brother==0.2.1 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -427,11 +428,8 @@ co2signal==0.4.2 # homeassistant.components.coinbase coinbase==2.1.0 -# homeassistant.components.coinmarketcap -coinmarketcap==5.0.3 - # homeassistant.scripts.check_config -colorlog==4.6.2 +colorlog==4.7.2 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -450,12 +448,6 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 -# homeassistant.scripts.credstash -# credstash==1.15.0 - -# homeassistant.components.crimereports -crimereports==1.0.1 - # homeassistant.components.datadog datadog==0.15.0 @@ -508,7 +500,7 @@ doorbirdpy==2.1.0 dovado==0.4.1 # homeassistant.components.dsmr -dsmr_parser==0.25 +dsmr_parser==0.28 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.3 @@ -541,7 +533,7 @@ eliqonline==1.2.2 elkm1-lib==0.8.10 # homeassistant.components.mobile_app -emoji==0.5.4 +emoji==1.2.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -579,6 +571,9 @@ eternalegypt==0.0.12 # homeassistant.components.evohome evohome-async==0.3.5.post1 +# homeassistant.components.faa_delays +faadelays==0.0.6 + # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify # face_recognition==1.2.3 @@ -613,6 +608,9 @@ foobot_async==1.0.0 # homeassistant.components.fortios fortiosapi==0.10.8 +# homeassistant.components.freebox +freebox-api==0.0.9 + # homeassistant.components.free_mobile freesms==0.1.2 @@ -625,7 +623,7 @@ fritzconnection==1.4.0 gTTS==2.2.2 # homeassistant.components.garmin_connect -garminconnect==0.1.16 +garminconnect==0.1.19 # homeassistant.components.geizhals geizhals==0.0.9 @@ -657,7 +655,7 @@ georss_qld_bushfire_alert_client==0.3 getmac==0.8.2 # homeassistant.components.gios -gios==0.1.4 +gios==0.1.5 # homeassistant.components.gitter gitterpy==0.1.7 @@ -684,7 +682,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.2.9 +google-nest-sdm==0.2.12 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -723,7 +721,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==0.0.8 +ha-philipsjs==2.3.0 # homeassistant.components.habitica habitipy==0.2.0 @@ -738,7 +736,7 @@ hass-nabucasa==0.41.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.2.7 +hatasmota==0.2.9 # homeassistant.components.jewish_calendar hdate==0.9.12 @@ -765,7 +763,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210127.7 +home-assistant-frontend==20210302.3 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -781,7 +779,7 @@ horimote==0.4.1 # homeassistant.components.google # homeassistant.components.remember_the_milk -httplib2==0.18.1 +httplib2==0.19.0 # homeassistant.components.huawei_lte huawei-lte-api==1.4.17 @@ -825,7 +823,7 @@ ihcsdk==2.7.0 incomfort-client==0.4.0 # homeassistant.components.influxdb -influxdb-client==1.8.0 +influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 @@ -843,12 +841,6 @@ kaiterra-async-client==0.0.2 # homeassistant.components.keba keba-kecontact==1.1.0 -# homeassistant.scripts.keyring -keyring==21.2.0 - -# homeassistant.scripts.keyring -keyrings.alt==3.4.0 - # homeassistant.components.kiwi kiwiki-client==0.1.1 @@ -957,6 +949,9 @@ mitemp_bt==0.0.3 # homeassistant.components.motion_blinds motionblinds==0.4.8 +# homeassistant.components.mullvad +mullvad-api==1.0.0 + # homeassistant.components.tts mutagen==1.45.1 @@ -973,7 +968,7 @@ n26==0.2.7 nad_receiver==0.0.12 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.0.11 +ndms2_client==0.1.1 # homeassistant.components.ness_alarm nessclient==0.9.15 @@ -1072,6 +1067,9 @@ openwebifpy==3.2.7 # homeassistant.components.luci openwrt-luci-rpc==1.1.6 +# homeassistant.components.ubus +openwrt-ubus-rpc==0.0.2 + # homeassistant.components.oru oru==0.1.11 @@ -1131,13 +1129,13 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.1.0 +pillow==8.1.1 # homeassistant.components.dominos pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.3.1 +plexapi==4.4.0 # homeassistant.components.plex plexauth==0.0.6 @@ -1159,7 +1157,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.1.0 +praw==7.1.4 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.3 @@ -1215,9 +1213,6 @@ py-nightscout==1.2.2 # homeassistant.components.schluter py-schluter==0.1.7 -# homeassistant.components.synology -py-synology==0.2.0 - # homeassistant.components.zabbix py-zabbix==1.1.7 @@ -1310,11 +1305,14 @@ pycfdns==1.2.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==8.0.0 +pychromecast==8.1.2 # homeassistant.components.pocketcasts pycketcasts==1.0.0 +# homeassistant.components.climacell +pyclimacell==0.14.0 + # homeassistant.components.cmus pycmus==0.1.1 @@ -1434,7 +1432,7 @@ pyheos==0.7.2 pyhik==0.2.8 # homeassistant.components.hive -pyhiveapi==0.2.20.2 +pyhiveapi==0.3.4.4 # homeassistant.components.homematic pyhomematic==0.1.71 @@ -1446,7 +1444,7 @@ pyhomeworks==0.0.6 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.8 +pyinsteon==1.0.9 # homeassistant.components.intesishome pyintesishome==1.7.5 @@ -1467,7 +1465,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==2.1.0 +pyisy==2.1.1 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -1475,6 +1473,9 @@ pyitachip2ir==0.0.7 # homeassistant.components.kira pykira==0.1.1 +# homeassistant.components.kmtronic +pykmtronic==0.0.3 + # homeassistant.components.kodi pykodi==0.2.1 @@ -1500,7 +1501,10 @@ pylgnetcast-homeassistant==0.2.0.dev0 pylibrespot-java==0.1.0 # homeassistant.components.litejet -pylitejet==0.1 +pylitejet==0.3.0 + +# homeassistant.components.litterrobot +pylitterbot==2021.2.5 # homeassistant.components.loopenergy pyloopenergy==0.2.1 @@ -1509,7 +1513,7 @@ pyloopenergy==0.2.1 pylutron-caseta==0.9.0 # homeassistant.components.lutron -pylutron==0.2.5 +pylutron==0.2.7 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1517,6 +1521,9 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 +# homeassistant.components.mazda +pymazda==0.0.8 + # homeassistant.components.mediaroom pymediaroom==0.6.4.1 @@ -1545,10 +1552,10 @@ pymsteams==0.1.12 pymusiccast==0.1.6 # homeassistant.components.myq -pymyq==3.0.1 +pymyq==3.0.4 # homeassistant.components.mysensors -pymysensors==0.18.0 +pymysensors==0.20.1 # homeassistant.components.nanoleaf pynanoleaf==0.0.5 @@ -1618,6 +1625,9 @@ pypck==0.7.9 # homeassistant.components.pjlink pypjlink2==1.2.1 +# homeassistant.components.plaato +pyplaato==0.0.15 + # homeassistant.components.point pypoint==2.0.0 @@ -1648,6 +1658,9 @@ pyrepetier==3.0.5 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.rituals_perfume_genie +pyrituals==0.0.2 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 @@ -1687,7 +1700,7 @@ pyskyqhub==0.1.3 pysma==0.3.5 # homeassistant.components.smappee -pysmappee==0.2.16 +pysmappee==0.2.17 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -1708,7 +1721,7 @@ pysnmp==4.4.12 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.37 +pysonos==0.0.40 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1747,7 +1760,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.8 +python-ecobee-api==0.2.10 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.11 @@ -1809,6 +1822,9 @@ python-qbittorrent==0.4.2 # homeassistant.components.ripple python-ripple-api==0.0.3 +# homeassistant.components.smarttub +python-smarttub==0.0.17 + # homeassistant.components.sochain python-sochain-api==0.0.2 @@ -1822,7 +1838,7 @@ python-tado==0.10.0 python-telegram-bot==13.1 # homeassistant.components.vlc_telnet -python-telnet-vlc==1.0.4 +python-telnet-vlc==2.0.1 # homeassistant.components.twitch python-twitch-client==0.6.0 @@ -1849,7 +1865,7 @@ python_opendata_transport==0.2.1 pythonegardia==1.0.40 # homeassistant.components.tile -pytile==5.1.0 +pytile==5.2.0 # homeassistant.components.touchline pytouchline==0.7 @@ -1874,7 +1890,7 @@ pyuptimerobot==0.0.5 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.3.11 +pyvera==0.3.13 # homeassistant.components.versasense pyversasense==0.0.6 @@ -1895,10 +1911,10 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.1 +pywemo==0.6.3 # homeassistant.components.wilight -pywilight==0.0.66 +pywilight==0.0.68 # homeassistant.components.xeoma pyxeoma==1.4.1 @@ -1940,7 +1956,7 @@ restrictedpython==5.1 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.55 +rflink==0.0.58 # homeassistant.components.ring ring_doorbell==0.6.2 @@ -1955,7 +1971,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.6.0 +rokuecp==0.8.0 # homeassistant.components.roomba roombapy==1.6.2 @@ -1964,7 +1980,7 @@ roombapy==1.6.2 roonapi==0.0.32 # homeassistant.components.rova -rova==0.1.0 +rova==0.2.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1985,7 +2001,7 @@ rxv==0.6.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws==1.4.0 +samsungtvws==1.6.0 # homeassistant.components.satel_integra satel_integra==0.3.4 @@ -2007,10 +2023,10 @@ sense-hat==2.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.8.1 +sense_energy==0.9.0 # homeassistant.components.sentry -sentry-sdk==0.19.5 +sentry-sdk==0.20.3 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2028,7 +2044,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.6.4 +simplisafe-python==9.6.9 # homeassistant.components.sisyphus sisyphus-control==3.0 @@ -2043,7 +2059,7 @@ slackclient==2.5.0 sleepyq==0.8.1 # homeassistant.components.xmpp -slixmpp==1.6.0 +slixmpp==1.7.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.4.0 @@ -2103,7 +2119,7 @@ spotipy==2.16.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.3.22 +sqlalchemy==1.3.23 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -2132,6 +2148,9 @@ streamlabswater==1.0.1 # homeassistant.components.traccar stringcase==1.2.0 +# homeassistant.components.subaru +subarulink==0.3.12 + # homeassistant.components.ecovacs sucks==0.9.4 @@ -2205,7 +2224,7 @@ todoist-python==8.0.0 toonapi==0.2.0 # homeassistant.components.totalconnect -total_connect_client==0.55 +total_connect_client==0.57 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2214,7 +2233,7 @@ tp-connected==0.0.4 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.9 +tuyaha==0.0.10 # homeassistant.components.twentemilieu twentemilieu==0.3.0 @@ -2263,7 +2282,7 @@ volkszaehler==0.2.1 volvooncall==0.8.12 # homeassistant.components.verisure -vsure==1.6.1 +vsure==1.7.2 # homeassistant.components.vasttrafik vtjp==0.1.14 @@ -2278,7 +2297,7 @@ wakeonlan==1.1.6 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==0.8.3 +watchdog==1.0.2 # homeassistant.components.waterfurnace waterfurnace==1.1.0 @@ -2313,11 +2332,8 @@ xbox-webapi==2.0.8 # homeassistant.components.xbox_live xboxapi==2.0.1 -# homeassistant.components.xfinity -xfinity-gateway==0.0.4 - # homeassistant.components.knx -xknx==0.16.2 +xknx==0.17.1 # homeassistant.components.bluesound # homeassistant.components.rest @@ -2339,7 +2355,7 @@ yeelight==0.5.4 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2021.01.16 +youtube_dl==2021.01.24.1 # homeassistant.components.onvif zeep[async]==4.0.0 @@ -2351,7 +2367,7 @@ zengge==0.2 zeroconf==0.28.8 # homeassistant.components.zha -zha-quirks==0.0.53 +zha-quirks==0.0.54 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2372,7 +2388,7 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.3.0 +zigpy-znp==0.4.0 # homeassistant.components.zha zigpy==0.32.0 @@ -2381,4 +2397,4 @@ zigpy==0.32.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.17.2 +zwave-js-server-python==0.20.1 diff --git a/requirements_test.txt b/requirements_test.txt index 380240e3ffc..12f215f177a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,12 +8,11 @@ codecov==2.1.10 coverage==5.4 jsonpickle==1.4.1 mock-open==1.4.0 -mypy==0.790 -pre-commit==2.9.3 +mypy==0.812 +pre-commit==2.10.1 pylint==2.6.0 astroid==2.4.2 pipdeptree==1.0.0 -awesomeversion==21.2.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 pytest-cov==2.10.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbc0e12fcb8..318b04e5e70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -3,8 +3,11 @@ -r requirements_test.txt +# homeassistant.components.aemet +AEMET-OpenData==0.1.8 + # homeassistant.components.homekit -HAP-python==3.1.0 +HAP-python==3.3.2 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -18,7 +21,7 @@ PyNaCl==1.3.0 PyQRCode==1.2.1 # homeassistant.components.rmvtransport -PyRMVtransport==0.2.10 +PyRMVtransport==0.3.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -39,13 +42,13 @@ WSDiscovery==2.0.0 abodepy==1.2.0 # homeassistant.components.accuweather -accuweather==0.0.11 +accuweather==0.1.0 # homeassistant.components.androidtv adb-shell[async]==0.2.1 # homeassistant.components.alarmdecoder -adext==0.3 +adext==0.4.1 # homeassistant.components.adguard adguardhome==0.4.2 @@ -88,19 +91,16 @@ aiodns==2.0.0 aioeafm==0.1.2 # homeassistant.components.esphome -aioesphomeapi==2.6.4 +aioesphomeapi==2.6.5 # homeassistant.components.flo aioflo==0.4.1 -# homeassistant.components.freebox -aiofreepybox==0.0.8 - # homeassistant.components.guardian aioguardian==1.0.4 # homeassistant.components.harmony -aioharmony==0.2.6 +aioharmony==0.2.7 # homeassistant.components.homekit_controller aiohomekit==0.2.60 @@ -118,6 +118,9 @@ aiokafka==0.6.0 # homeassistant.components.lutron_caseta aiolip==1.1.4 +# homeassistant.components.lyric +aiolyric==1.0.5 + # homeassistant.components.notion aionotion==1.1.0 @@ -137,7 +140,7 @@ aiopylgtv==0.3.3 aiorecollect==1.0.1 # homeassistant.components.shelly -aioshelly==0.5.1.beta0 +aioshelly==0.6.1 # homeassistant.components.switcher_kis aioswitcher==1.2.1 @@ -170,6 +173,7 @@ aprslib==0.6.46 arcam-fmj==0.5.3 # homeassistant.components.dlna_dmr +# homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 @@ -177,7 +181,7 @@ async-upnp-client==0.14.13 auroranoaa==0.0.2 # homeassistant.components.stream -av==8.0.2 +av==8.0.3 # homeassistant.components.axis axis==43 @@ -189,19 +193,19 @@ azure-eventhub==5.1.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.21.0 +bellows==0.22.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.14 +bimmer_connected==0.7.15 # homeassistant.components.blebox blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.16.4 +blinkpy==0.17.0 # homeassistant.components.bond -bond-api==0.1.8 +bond-api==0.1.11 # homeassistant.components.braviatv bravia-tv==1.0.8 @@ -210,7 +214,7 @@ bravia-tv==1.0.8 broadlink==0.16.0 # homeassistant.components.brother -brother==0.1.21 +brother==0.2.1 # homeassistant.components.bsblan bsblan==0.4.0 @@ -221,11 +225,8 @@ buienradar==1.0.4 # homeassistant.components.caldav caldav==0.7.1 -# homeassistant.components.coinmarketcap -coinmarketcap==5.0.3 - # homeassistant.scripts.check_config -colorlog==4.6.2 +colorlog==4.7.2 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -238,9 +239,6 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 -# homeassistant.scripts.credstash -# credstash==1.15.0 - # homeassistant.components.datadog datadog==0.15.0 @@ -272,7 +270,7 @@ distro==1.5.0 doorbirdpy==2.1.0 # homeassistant.components.dsmr -dsmr_parser==0.25 +dsmr_parser==0.28 # homeassistant.components.dynalite dynalite_devices==0.1.46 @@ -287,7 +285,7 @@ elgato==1.0.0 elkm1-lib==0.8.10 # homeassistant.components.mobile_app -emoji==0.5.4 +emoji==1.2.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -301,6 +299,9 @@ ephem==3.7.7.0 # homeassistant.components.epson epson-projector==0.2.3 +# homeassistant.components.faa_delays +faadelays==0.0.6 + # homeassistant.components.feedreader feedparser==6.0.2 @@ -310,6 +311,9 @@ fnvhash==0.1.0 # homeassistant.components.foobot foobot_async==1.0.0 +# homeassistant.components.freebox +freebox-api==0.0.9 + # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor # homeassistant.components.fritzbox_netmonitor @@ -319,7 +323,7 @@ fritzconnection==1.4.0 gTTS==2.2.2 # homeassistant.components.garmin_connect -garminconnect==0.1.16 +garminconnect==0.1.19 # homeassistant.components.geo_json_events # homeassistant.components.usgs_earthquakes_feed @@ -345,7 +349,7 @@ georss_qld_bushfire_alert_client==0.3 getmac==0.8.2 # homeassistant.components.gios -gios==0.1.4 +gios==0.1.5 # homeassistant.components.glances glances_api==0.2.0 @@ -363,7 +367,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.2.9 +google-nest-sdm==0.2.12 # homeassistant.components.gree greeclimate==0.10.3 @@ -377,6 +381,12 @@ guppy3==3.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 +# homeassistant.components.philips_js +ha-philipsjs==2.3.0 + +# homeassistant.components.habitica +habitipy==0.2.0 + # homeassistant.components.hangouts hangups==0.4.11 @@ -384,7 +394,7 @@ hangups==0.4.11 hass-nabucasa==0.41.0 # homeassistant.components.tasmota -hatasmota==0.2.7 +hatasmota==0.2.9 # homeassistant.components.jewish_calendar hdate==0.9.12 @@ -402,7 +412,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210127.7 +home-assistant-frontend==20210302.3 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -415,7 +425,7 @@ homematicip==0.13.1 # homeassistant.components.google # homeassistant.components.remember_the_milk -httplib2==0.18.1 +httplib2==0.19.0 # homeassistant.components.huawei_lte huawei-lte-api==1.4.17 @@ -433,7 +443,7 @@ iaqualink==0.3.4 icmplib==2.0 # homeassistant.components.influxdb -influxdb-client==1.8.0 +influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 @@ -442,12 +452,6 @@ influxdb==5.2.3 # homeassistant.components.verisure jsonpath==0.82 -# homeassistant.scripts.keyring -keyring==21.2.0 - -# homeassistant.scripts.keyring -keyrings.alt==3.4.0 - # homeassistant.components.konnected konnected==1.2.0 @@ -490,9 +494,15 @@ minio==4.0.9 # homeassistant.components.motion_blinds motionblinds==0.4.8 +# homeassistant.components.mullvad +mullvad-api==1.0.0 + # homeassistant.components.tts mutagen==1.45.1 +# homeassistant.components.keenetic_ndms2 +ndms2_client==0.1.1 + # homeassistant.components.ness_alarm nessclient==0.9.15 @@ -568,10 +578,10 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.1.0 +pillow==8.1.1 # homeassistant.components.plex -plexapi==4.3.1 +plexapi==4.4.0 # homeassistant.components.plex plexauth==0.0.6 @@ -593,7 +603,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.1.0 +praw==7.1.4 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.3 @@ -678,7 +688,10 @@ pybotvac==0.0.20 pycfdns==1.2.1 # homeassistant.components.cast -pychromecast==8.0.0 +pychromecast==8.1.2 + +# homeassistant.components.climacell +pyclimacell==0.14.0 # homeassistant.components.comfoconnect pycomfoconnect==0.4 @@ -745,7 +758,7 @@ pyhomematic==0.1.71 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.8 +pyinsteon==1.0.9 # homeassistant.components.ipma pyipma==2.0.5 @@ -757,11 +770,14 @@ pyipp==0.11.0 pyiqvia==0.3.1 # homeassistant.components.isy994 -pyisy==2.1.0 +pyisy==2.1.1 # homeassistant.components.kira pykira==0.1.1 +# homeassistant.components.kmtronic +pykmtronic==0.0.3 + # homeassistant.components.kodi pykodi==0.2.1 @@ -775,7 +791,10 @@ pylast==4.1.0 pylibrespot-java==0.1.0 # homeassistant.components.litejet -pylitejet==0.1 +pylitejet==0.3.0 + +# homeassistant.components.litterrobot +pylitterbot==2021.2.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.9.0 @@ -786,6 +805,9 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 +# homeassistant.components.mazda +pymazda==0.0.8 + # homeassistant.components.melcloud pymelcloud==2.5.2 @@ -802,7 +824,13 @@ pymodbus==2.3.0 pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.0.1 +pymyq==3.0.4 + +# homeassistant.components.mysensors +pymysensors==0.20.1 + +# homeassistant.components.nuki +pynuki==1.3.8 # homeassistant.components.nut pynut2==2.1.2 @@ -836,6 +864,9 @@ pyowm==3.1.1 # homeassistant.components.onewire pyownet==0.10.0.post1 +# homeassistant.components.plaato +pyplaato==0.0.15 + # homeassistant.components.point pypoint==2.0.0 @@ -851,6 +882,9 @@ pyqwikswitch==0.93 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.rituals_perfume_genie +pyrituals==0.0.2 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 @@ -869,7 +903,7 @@ pysignalclirestapi==0.3.4 pysma==0.3.5 # homeassistant.components.smappee -pysmappee==0.2.16 +pysmappee==0.2.17 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -881,7 +915,7 @@ pysmartthings==0.7.6 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.37 +pysonos==0.0.40 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -893,7 +927,7 @@ pysqueezebox==0.5.5 pysyncthru==0.7.0 # homeassistant.components.ecobee -python-ecobee-api==0.2.8 +python-ecobee-api==0.2.10 # homeassistant.components.darksky python-forecastio==1.4.0 @@ -913,6 +947,9 @@ python-nest==4.1.0 # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 +# homeassistant.components.smarttub +python-smarttub==0.0.17 + # homeassistant.components.songpal python-songpal==0.12 @@ -929,7 +966,7 @@ python-velbus==2.1.2 python_awair==0.2.1 # homeassistant.components.tile -pytile==5.1.0 +pytile==5.2.0 # homeassistant.components.traccar pytraccar==0.9.0 @@ -938,7 +975,7 @@ pytraccar==0.9.0 pytradfri[async]==7.0.6 # homeassistant.components.vera -pyvera==0.3.11 +pyvera==0.3.13 # homeassistant.components.vesync pyvesync==1.2.0 @@ -953,10 +990,10 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.1 +pywemo==0.6.3 # homeassistant.components.wilight -pywilight==0.0.66 +pywilight==0.0.68 # homeassistant.components.zerproc pyzerproc==0.4.7 @@ -971,13 +1008,13 @@ regenmaschine==3.0.0 restrictedpython==5.1 # homeassistant.components.rflink -rflink==0.0.55 +rflink==0.0.58 # homeassistant.components.ring ring_doorbell==0.6.2 # homeassistant.components.roku -rokuecp==0.6.0 +rokuecp==0.8.0 # homeassistant.components.roomba roombapy==1.6.2 @@ -995,17 +1032,17 @@ rxv==0.6.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws==1.4.0 +samsungtvws==1.6.0 # homeassistant.components.dhcp scapy==2.4.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.8.1 +sense_energy==0.9.0 # homeassistant.components.sentry -sentry-sdk==0.19.5 +sentry-sdk==0.20.3 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1014,7 +1051,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.6.4 +simplisafe-python==9.6.9 # homeassistant.components.slack slackclient==2.5.0 @@ -1057,7 +1094,7 @@ spotipy==2.16.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.3.22 +sqlalchemy==1.3.23 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -1074,6 +1111,9 @@ statsd==3.2.1 # homeassistant.components.traccar stringcase==1.2.0 +# homeassistant.components.subaru +subarulink==0.3.12 + # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -1096,13 +1136,13 @@ teslajsonpy==0.11.5 toonapi==0.2.0 # homeassistant.components.totalconnect -total_connect_client==0.55 +total_connect_client==0.57 # homeassistant.components.transmission transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.9 +tuyaha==0.0.10 # homeassistant.components.twentemilieu twentemilieu==0.3.0 @@ -1130,7 +1170,7 @@ uvcclient==0.11.0 vilfo-api-client==0.3.2 # homeassistant.components.verisure -vsure==1.6.1 +vsure==1.7.2 # homeassistant.components.vultr vultr==0.1.2 @@ -1139,7 +1179,7 @@ vultr==0.1.2 wakeonlan==1.1.6 # homeassistant.components.folder_watcher -watchdog==0.8.3 +watchdog==1.0.2 # homeassistant.components.wiffi wiffi==1.0.1 @@ -1173,7 +1213,7 @@ zeep[async]==4.0.0 zeroconf==0.28.8 # homeassistant.components.zha -zha-quirks==0.0.53 +zha-quirks==0.0.54 # homeassistant.components.zha zigpy-cc==0.5.2 @@ -1188,10 +1228,10 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.3.0 +zigpy-znp==0.4.0 # homeassistant.components.zha zigpy==0.32.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.17.2 +zwave-js-server-python==0.20.1 diff --git a/script/bootstrap b/script/bootstrap index f58268ff1a8..b641ec7e8c0 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -6,14 +6,6 @@ set -e cd "$(dirname "$0")/.." -# Add default vscode settings if not existing -SETTINGS_FILE=./.vscode/settings.json -SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json -if [ ! -f "$SETTINGS_FILE" ]; then - echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." - cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" -fi - echo "Installing development dependencies..." python3 -m pip install wheel --constraint homeassistant/package_constraints.txt -python3 -m pip install tox colorlog pre-commit $(grep mypy requirements_test.txt) $(grep stdlib-list requirements_test.txt) $(grep tqdm requirements_test.txt) $(grep pipdeptree requirements_test.txt) --constraint homeassistant/package_constraints.txt +python3 -m pip install tox tox-pip-version colorlog pre-commit $(grep mypy requirements_test.txt) $(grep stdlib-list requirements_test.txt) $(grep tqdm requirements_test.txt) $(grep pipdeptree requirements_test.txt) $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dc1ef9a471b..94365be9a50 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -21,14 +21,12 @@ COMMENT_REQUIREMENTS = ( "blinkt", "bluepy", "bme680", - "credstash", "decora", "decora_wifi", "envirophat", "evdev", "face_recognition", "i2csense", - "nuimo", "opencv-python-headless", "py_noaa", "pybluez", @@ -48,7 +46,7 @@ COMMENT_REQUIREMENTS = ( "VL53L1X2", ) -IGNORE_PIN = ("colorlog>2.1,<3", "keyring>=9.3,<10.0", "urllib3") +IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") URL_PIN = ( "https://developers.home-assistant.io/docs/" @@ -72,8 +70,9 @@ h11>=0.12.0 # https://github.com/encode/httpcore/issues/239 httpcore>=0.12.3 -# Constrain httplib2 to protect against CVE-2020-11078 -httplib2>=0.18.0 +# Constrain httplib2 to protect against GHSA-93xj-8mrv-444m +# https://github.com/advisories/GHSA-93xj-8mrv-444m +httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 3beb6aadfc5..c6f8f71f2d9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -2,11 +2,11 @@ from typing import Dict from urllib.parse import urlparse -from awesomeversion import AwesomeVersion -from awesomeversion.strategy import AwesomeVersionStrategy import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.loader import validate_custom_integration_version + from .model import Integration DOCUMENTATION_URL_SCHEMA = "https" @@ -53,16 +53,9 @@ def verify_uppercase(value: str): def verify_version(value: str): """Verify the version.""" - version = AwesomeVersion(value) - if version.strategy not in [ - AwesomeVersionStrategy.CALVER, - AwesomeVersionStrategy.SEMVER, - AwesomeVersionStrategy.SIMPLEVER, - AwesomeVersionStrategy.BUILDVER, - AwesomeVersionStrategy.PEP440, - ]: + if not validate_custom_integration_version(value): raise vol.Invalid( - f"'{version}' is not a valid version. This will cause a future version of Home Assistant to block this integration.", + f"'{value}' is not a valid version. This will cause a future version of Home Assistant to block this integration.", ) return value @@ -126,7 +119,7 @@ def validate_version(integration: Integration): Will be removed when the version key is no longer optional for custom integrations. """ if not integration.manifest.get("version"): - integration.add_warning( + integration.add_error( "manifest", "No 'version' key in the manifest file. This will cause a future version of Home Assistant to block this integration.", ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index c07d3bbc6ef..62e9b2f88a1 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -24,10 +24,12 @@ def exists(value): FIELD_SCHEMA = vol.Schema( { vol.Required("description"): str, + vol.Optional("name"): str, vol.Optional("example"): exists, vol.Optional("default"): exists, vol.Optional("values"): exists, vol.Optional("required"): bool, + vol.Optional("advanced"): bool, vol.Optional(CONF_SELECTOR): selector.validate_selector, } ) @@ -35,6 +37,10 @@ FIELD_SCHEMA = vol.Schema( SERVICE_SCHEMA = vol.Schema( { vol.Required("description"): str, + vol.Optional("name"): str, + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None # pylint: disable=no-member + ), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ) @@ -60,7 +66,9 @@ def validate_services(integration: Integration): """Validate services.""" # Find if integration uses services has_services = grep_dir( - integration.path, "**/*.py", r"hass\.services\.(register|async_register)" + integration.path, + "**/*.py", + r"(hass\.services\.(register|async_register))|async_register_entity_service", ) if not has_services: diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 20b5a03206e..c51061b57fe 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -65,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # If using a requests-based API lib - hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, entry, session) + hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, session) # If using an aiohttp-based API lib hass.data[DOMAIN][entry.entry_id] = api.AsyncConfigEntryAuth( diff --git a/script/scaffold/templates/config_flow_oauth2/integration/api.py b/script/scaffold/templates/config_flow_oauth2/integration/api.py index 710c76600fb..50b54399579 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/api.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/api.py @@ -4,7 +4,7 @@ from asyncio import run_coroutine_threadsafe from aiohttp import ClientSession import my_pypi_package -from homeassistant import config_entries, core +from homeassistant import core from homeassistant.helpers import config_entry_oauth2_flow # TODO the following two API examples are based on our suggested best practices @@ -18,15 +18,11 @@ class ConfigEntryAuth(my_pypi_package.AbstractAuth): def __init__( self, hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ): """Initialize NEW_NAME Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) + self.session = oauth_session super().__init__(self.session.token) def refresh_tokens(self) -> str: diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 7709813957e..2fa59d4eac8 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -84,16 +84,13 @@ async def async_attach_trigger( # Use the existing state or event triggers from the automation integration. if config[CONF_TYPE] == "turned_on": - from_state = STATE_OFF to_state = STATE_ON else: - from_state = STATE_ON to_state = STATE_OFF state_config = { state.CONF_PLATFORM: "state", CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state.CONF_FROM: from_state, state.CONF_TO: to_state, } state_config = state.TRIGGER_SCHEMA(state_config) diff --git a/script/setup b/script/setup index 83c2d24f038..46865ecfcb6 100755 --- a/script/setup +++ b/script/setup @@ -6,10 +6,20 @@ set -e cd "$(dirname "$0")/.." +# Add default vscode settings if not existing +SETTINGS_FILE=./.vscode/settings.json +SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json +if [ ! -f "$SETTINGS_FILE" ]; then + echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." + cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" +fi + mkdir -p config -python3 -m venv venv -source venv/bin/activate +if [ ! -n "$DEVCONTAINER" ];then + python3 -m venv venv + source venv/bin/activate +fi script/bootstrap diff --git a/script/translations/lokalise.py b/script/translations/lokalise.py index 69860b49e45..a23291169f4 100644 --- a/script/translations/lokalise.py +++ b/script/translations/lokalise.py @@ -1,4 +1,6 @@ """API for Lokalise.""" +from __future__ import annotations + from pprint import pprint import requests @@ -6,7 +8,7 @@ import requests from .util import get_lokalise_token -def get_api(project_id, debug=False) -> "Lokalise": +def get_api(project_id, debug=False) -> Lokalise: """Get Lokalise API.""" return Lokalise(project_id, get_lokalise_token(), debug) diff --git a/setup.py b/setup.py index 84b19d15762..ce7d6b6883d 100755 --- a/setup.py +++ b/setup.py @@ -32,22 +32,22 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - "aiohttp==3.7.3", + "aiohttp==3.7.4", "astral==1.10.1", "async_timeout==3.0.1", "attrs==19.3.0", - "awesomeversion==21.2.2", + "awesomeversion==21.2.3", "bcrypt==3.1.7", "certifi>=2020.12.5", "ciso8601==2.1.3", "httpx==0.16.1", - "jinja2>=2.11.2", + "jinja2>=2.11.3", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==3.2", + "cryptography==3.3.2", "pip>=8.0.3,<20.3", "python-slugify==4.0.1", - "pytz>=2020.5", + "pytz>=2021.1", "pyyaml==5.4.1", "requests==2.25.1", "ruamel.yaml==0.15.100", diff --git a/tests/auth/mfa_modules/test_insecure_example.py b/tests/auth/mfa_modules/test_insecure_example.py index 5384ebee4bd..035433986d4 100644 --- a/tests/auth/mfa_modules/test_insecure_example.py +++ b/tests/auth/mfa_modules/test_insecure_example.py @@ -131,7 +131,7 @@ async def test_login(hass): result["flow_id"], {"pin": "123456"} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"].id == "mock-user" + assert result["data"].id == "mock-id" async def test_setup_flow(hass): diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py index c79d76baf4f..1d08ad70cc8 100644 --- a/tests/auth/mfa_modules/test_notify.py +++ b/tests/auth/mfa_modules/test_notify.py @@ -229,7 +229,7 @@ async def test_login_flow_validates_mfa(hass): result["flow_id"], {"code": MOCK_CODE} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"].id == "mock-user" + assert result["data"].id == "mock-id" async def test_setup_user_notify_service(hass): diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py index d0a4f3cf3ac..2e4aad98066 100644 --- a/tests/auth/mfa_modules/test_totp.py +++ b/tests/auth/mfa_modules/test_totp.py @@ -127,7 +127,7 @@ async def test_login_flow_validates_mfa(hass): result["flow_id"], {"code": MOCK_CODE} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"].id == "mock-user" + assert result["data"].id == "mock-id" async def test_race_condition_in_data_loading(hass): diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 3156c40f876..4ece4875ba4 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -1,5 +1,6 @@ """Test the Trusted Networks auth provider.""" from ipaddress import ip_address, ip_network +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -142,6 +143,16 @@ async def test_validate_access(provider): provider.async_validate_access(ip_address("2001:db8::ff00:42:8329")) +async def test_validate_refresh_token(provider): + """Verify re-validation of refresh token.""" + with patch.object(provider, "async_validate_access") as mock: + with pytest.raises(tn_auth.InvalidAuthError): + provider.async_validate_refresh_token(Mock(), None) + + provider.async_validate_refresh_token(Mock(), "127.0.0.1") + mock.assert_called_once_with(ip_address("127.0.0.1")) + + async def test_login_flow(manager, provider): """Test login flow.""" owner = await manager.async_create_user("test-owner") diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 4ab0fc4a360..0c650adba3c 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -37,6 +37,7 @@ async def test_loading_no_group_data_format(hass, hass_storage): "last_used_at": "2018-10-03T13:43:19.774712+00:00", "token": "some-token", "user_id": "user-id", + "version": "1.2.3", }, { "access_token_expiration": 1800.0, @@ -87,12 +88,14 @@ async def test_loading_no_group_data_format(hass, hass_storage): assert len(owner.refresh_tokens) == 1 owner_token = list(owner.refresh_tokens.values())[0] assert owner_token.id == "user-token-id" + assert owner_token.version == "1.2.3" assert system.system_generated is True assert system.groups == [] assert len(system.refresh_tokens) == 1 system_token = list(system.refresh_tokens.values())[0] assert system_token.id == "system-token-id" + assert system_token.version is None async def test_loading_all_access_group_data_format(hass, hass_storage): @@ -129,6 +132,7 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): "last_used_at": "2018-10-03T13:43:19.774712+00:00", "token": "some-token", "user_id": "user-id", + "version": "1.2.3", }, { "access_token_expiration": 1800.0, @@ -139,6 +143,7 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): "last_used_at": "2018-10-03T13:43:19.774712+00:00", "token": "some-token", "user_id": "system-id", + "version": None, }, { "access_token_expiration": 1800.0, @@ -179,12 +184,14 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): assert len(owner.refresh_tokens) == 1 owner_token = list(owner.refresh_tokens.values())[0] assert owner_token.id == "user-token-id" + assert owner_token.version == "1.2.3" assert system.system_generated is True assert system.groups == [] assert len(system.refresh_tokens) == 1 system_token = list(system.refresh_tokens.values())[0] assert system_token.id == "system-token-id" + assert system_token.version is None async def test_loading_empty_data(hass, hass_storage): diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index edcd01d51e1..4f34ce1d595 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -7,7 +7,12 @@ import pytest import voluptuous as vol from homeassistant import auth, data_entry_flow -from homeassistant.auth import auth_store, const as auth_const, models as auth_models +from homeassistant.auth import ( + InvalidAuthError, + auth_store, + const as auth_const, + models as auth_models, +) from homeassistant.auth.const import MFA_SESSION_EXPIRATION from homeassistant.core import callback from homeassistant.util import dt as dt_util @@ -162,7 +167,10 @@ async def test_create_new_user(hass): step["flow_id"], {"username": "test-user", "password": "test-pass"} ) assert step["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - user = step["result"] + credential = step["result"] + assert credential is not None + + user = await manager.async_get_or_create_user(credential) assert user is not None assert user.is_owner is False assert user.name == "Test Name" @@ -229,7 +237,8 @@ async def test_login_as_existing_user(mock_hass): ) assert step["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - user = step["result"] + credential = step["result"] + user = await manager.async_get_user_by_credentials(credential) assert user is not None assert user.id == "mock-user" assert user.is_owner is False @@ -259,7 +268,8 @@ async def test_linking_user_to_two_auth_providers(hass, hass_storage): step = await manager.login_flow.async_configure( step["flow_id"], {"username": "test-user", "password": "test-pass"} ) - user = step["result"] + credential = step["result"] + user = await manager.async_get_or_create_user(credential) assert user is not None step = await manager.login_flow.async_init( @@ -293,13 +303,19 @@ async def test_saving_loading(hass, hass_storage): step = await manager.login_flow.async_configure( step["flow_id"], {"username": "test-user", "password": "test-pass"} ) - user = step["result"] + credential = step["result"] + user = await manager.async_get_or_create_user(credential) + await manager.async_activate_user(user) # the first refresh token will be used to create access token - refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + refresh_token = await manager.async_create_refresh_token( + user, CLIENT_ID, credential=credential + ) manager.async_create_access_token(refresh_token, "192.168.0.1") # the second refresh token will not be used - await manager.async_create_refresh_token(user, "dummy-client") + await manager.async_create_refresh_token( + user, "dummy-client", credential=credential + ) await flush_store(manager._store._store) @@ -452,6 +468,46 @@ async def test_refresh_token_type_long_lived_access_token(hass): assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN +async def test_refresh_token_provider_validation(mock_hass): + """Test that creating access token from refresh token checks with provider.""" + manager = await auth.auth_manager_from_config( + mock_hass, + [ + { + "type": "insecure_example", + "users": [{"username": "test-user", "password": "test-pass"}], + } + ], + [], + ) + + credential = auth_models.Credentials( + id="mock-credential-id", + auth_provider_type="insecure_example", + auth_provider_id=None, + data={"username": "test-user"}, + is_new=False, + ) + + user = MockUser().add_to_auth_manager(manager) + user.credentials.append(credential) + refresh_token = await manager.async_create_refresh_token( + user, CLIENT_ID, credential=credential + ) + ip = "127.0.0.1" + + assert manager.async_create_access_token(refresh_token, ip) is not None + + with patch( + "homeassistant.auth.providers.insecure_example.ExampleAuthProvider.async_validate_refresh_token", + side_effect=InvalidAuthError("Invalid access"), + ) as call: + with pytest.raises(InvalidAuthError): + manager.async_create_access_token(refresh_token, ip) + + call.assert_called_with(refresh_token, ip) + + async def test_cannot_deactive_owner(mock_hass): """Test that we cannot deactivate the owner.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) @@ -626,14 +682,10 @@ async def test_login_with_auth_module(mock_hass): step["flow_id"], {"pin": "test-pin"} ) - # Finally passed, get user + # Finally passed, get credential assert step["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - user = step["result"] - assert user is not None - assert user.id == "mock-user" - assert user.is_owner is False - assert user.is_active is False - assert user.name == "Paulus" + assert step["result"] + assert step["result"].id == "mock-id" async def test_login_with_multi_auth_module(mock_hass): @@ -703,14 +755,10 @@ async def test_login_with_multi_auth_module(mock_hass): step["flow_id"], {"pin": "test-pin2"} ) - # Finally passed, get user + # Finally passed, get credential assert step["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - user = step["result"] - assert user is not None - assert user.id == "mock-user" - assert user.is_owner is False - assert user.is_active is False - assert user.name == "Paulus" + assert step["result"] + assert step["result"].id == "mock-id" async def test_auth_module_expired_session(mock_hass): @@ -792,7 +840,8 @@ async def test_enable_mfa_for_user(hass, hass_storage): step = await manager.login_flow.async_configure( step["flow_id"], {"username": "test-user", "password": "test-pass"} ) - user = step["result"] + credential = step["result"] + user = await manager.async_get_or_create_user(credential) assert user is not None # new user don't have mfa enabled diff --git a/tests/common.py b/tests/common.py index 2621f2f4b15..52d368853b3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,6 +12,9 @@ import os import pathlib import threading import time +from time import monotonic +import types +from typing import Any, Awaitable, Collection, Optional from unittest.mock import AsyncMock, Mock, patch import uuid @@ -43,7 +46,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import State +from homeassistant.core import BLOCK_LOG_TIMEOUT, State from homeassistant.helpers import ( area_registry, device_registry, @@ -143,7 +146,7 @@ def get_test_home_assistant(): # pylint: disable=protected-access -async def async_test_home_assistant(loop): +async def async_test_home_assistant(loop, load_registries=True): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant() store = auth_store.AuthStore(hass) @@ -190,9 +193,76 @@ async def async_test_home_assistant(loop): return orig_async_create_task(coroutine) + async def async_wait_for_task_count(self, max_remaining_tasks: int = 0) -> None: + """Block until at most max_remaining_tasks remain. + + Based on HomeAssistant.async_block_till_done + """ + # To flush out any call_soon_threadsafe + await asyncio.sleep(0) + start_time: Optional[float] = None + + while len(self._pending_tasks) > max_remaining_tasks: + pending = [ + task for task in self._pending_tasks if not task.done() + ] # type: Collection[Awaitable[Any]] + self._pending_tasks.clear() + if len(pending) > max_remaining_tasks: + remaining_pending = await self._await_count_and_log_pending( + pending, max_remaining_tasks=max_remaining_tasks + ) + self._pending_tasks.extend(remaining_pending) + + if start_time is None: + # Avoid calling monotonic() until we know + # we may need to start logging blocked tasks. + start_time = 0 + elif start_time == 0: + # If we have waited twice then we set the start + # time + start_time = monotonic() + elif monotonic() - start_time > BLOCK_LOG_TIMEOUT: + # We have waited at least three loops and new tasks + # continue to block. At this point we start + # logging all waiting tasks. + for task in pending: + _LOGGER.debug("Waiting for task: %s", task) + else: + self._pending_tasks.extend(pending) + await asyncio.sleep(0) + + async def _await_count_and_log_pending( + self, pending: Collection[Awaitable[Any]], max_remaining_tasks: int = 0 + ) -> Collection[Awaitable[Any]]: + """Block at most max_remaining_tasks remain and log tasks that take a long time. + + Based on HomeAssistant._await_and_log_pending + """ + wait_time = 0 + + return_when = asyncio.ALL_COMPLETED + if max_remaining_tasks: + return_when = asyncio.FIRST_COMPLETED + + while len(pending) > max_remaining_tasks: + _, pending = await asyncio.wait( + pending, timeout=BLOCK_LOG_TIMEOUT, return_when=return_when + ) + if not pending or max_remaining_tasks: + return pending + wait_time += BLOCK_LOG_TIMEOUT + for task in pending: + _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + + return [] + hass.async_add_job = async_add_job hass.async_add_executor_job = async_add_executor_job hass.async_create_task = async_create_task + hass.async_wait_for_task_count = types.MethodType(async_wait_for_task_count, hass) + hass._await_count_and_log_pending = types.MethodType( + _await_count_and_log_pending, hass + ) hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} @@ -210,6 +280,15 @@ async def async_test_home_assistant(loop): hass.config_entries._entries = [] hass.config_entries._store._async_ensure_stop_listener = lambda: None + # Load the registries + if load_registries: + await asyncio.gather( + device_registry.async_load(hass), + entity_registry.async_load(hass), + area_registry.async_load(hass), + ) + await hass.async_block_till_done() + hass.state = ha.CoreState.running # Mock async_start @@ -670,6 +749,7 @@ class MockConfigEntry(config_entries.ConfigEntry): system_options={}, connection_class=config_entries.CONN_CLASS_UNKNOWN, unique_id=None, + disabled_by=None, ): """Initialize a mock config entry.""" kwargs = { @@ -682,6 +762,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "title": title, "connection_class": connection_class, "unique_id": unique_id, + "disabled_by": disabled_by, } if source is not None: kwargs["source"] = source diff --git a/tests/components/aemet/__init__.py b/tests/components/aemet/__init__.py new file mode 100644 index 00000000000..a92ff2764b1 --- /dev/null +++ b/tests/components/aemet/__init__.py @@ -0,0 +1 @@ +"""Tests for the AEMET OpenData integration.""" diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py new file mode 100644 index 00000000000..3c93a9d6321 --- /dev/null +++ b/tests/components/aemet/test_config_flow.py @@ -0,0 +1,100 @@ +"""Define tests for the AEMET OpenData config flow.""" + +from unittest.mock import MagicMock, patch + +import requests_mock + +from homeassistant import data_entry_flow +from homeassistant.components.aemet.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +import homeassistant.util.dt as dt_util + +from .util import aemet_requests_mock + +from tests.common import MockConfigEntry + +CONFIG = { + CONF_NAME: "aemet", + CONF_API_KEY: "foo", + CONF_LATITUDE: 40.30403754, + CONF_LONGITUDE: -3.72935236, +} + + +async def test_form(hass): + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.aemet.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.aemet.async_setup_entry", + return_value=True, + ) as mock_setup_entry, requests_mock.mock() as _m: + aemet_requests_mock(_m) + + 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state == ENTRY_STATE_LOADED + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONFIG[CONF_NAME] + assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] + assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] + assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicated_id(hass): + """Test that the options form.""" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ), requests_mock.mock() as _m: + aemet_requests_mock(_m) + + entry = MockConfigEntry( + domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form_api_offline(hass): + """Test setting up with api call error.""" + mocked_aemet = MagicMock() + + mocked_aemet.get_conventional_observation_stations.return_value = None + + with patch( + "homeassistant.components.aemet.config_flow.AEMET", + return_value=mocked_aemet, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {"base": "invalid_api_key"} diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py new file mode 100644 index 00000000000..f1c6c48f3f3 --- /dev/null +++ b/tests/components/aemet/test_init.py @@ -0,0 +1,44 @@ +"""Define tests for the AEMET OpenData init.""" + +from unittest.mock import patch + +import requests_mock + +from homeassistant.components.aemet.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +import homeassistant.util.dt as dt_util + +from .util import aemet_requests_mock + +from tests.common import MockConfigEntry + +CONFIG = { + CONF_NAME: "aemet", + CONF_API_KEY: "foo", + CONF_LATITUDE: 40.30403754, + CONF_LONGITUDE: -3.72935236, +} + + +async def test_unload_entry(hass): + """Test that the options form.""" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ), requests_mock.mock() as _m: + aemet_requests_mock(_m) + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py new file mode 100644 index 00000000000..05f2d8d0b50 --- /dev/null +++ b/tests/components/aemet/test_sensor.py @@ -0,0 +1,137 @@ +"""The sensor tests for the AEMET OpenData platform.""" + +from unittest.mock import patch + +from homeassistant.components.weather import ( + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_SNOWY, +) +from homeassistant.const import STATE_UNKNOWN +import homeassistant.util.dt as dt_util + +from .util import async_init_integration + + +async def test_aemet_forecast_create_sensors(hass): + """Test creation of forecast sensors.""" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ): + await async_init_integration(hass) + + state = hass.states.get("sensor.aemet_daily_forecast_condition") + assert state.state == ATTR_CONDITION_PARTLYCLOUDY + + state = hass.states.get("sensor.aemet_daily_forecast_precipitation") + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.aemet_daily_forecast_precipitation_probability") + assert state.state == "30" + + state = hass.states.get("sensor.aemet_daily_forecast_temperature") + assert state.state == "4" + + state = hass.states.get("sensor.aemet_daily_forecast_temperature_low") + assert state.state == "-4" + + state = hass.states.get("sensor.aemet_daily_forecast_time") + assert state.state == "2021-01-10 00:00:00+00:00" + + state = hass.states.get("sensor.aemet_daily_forecast_wind_bearing") + assert state.state == "45.0" + + state = hass.states.get("sensor.aemet_daily_forecast_wind_speed") + assert state.state == "20" + + state = hass.states.get("sensor.aemet_hourly_forecast_condition") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_precipitation") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_precipitation_probability") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_temperature") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_temperature_low") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_time") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_wind_bearing") + assert state is None + + state = hass.states.get("sensor.aemet_hourly_forecast_wind_speed") + assert state is None + + +async def test_aemet_weather_create_sensors(hass): + """Test creation of weather sensors.""" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ): + await async_init_integration(hass) + + state = hass.states.get("sensor.aemet_condition") + assert state.state == ATTR_CONDITION_SNOWY + + state = hass.states.get("sensor.aemet_humidity") + assert state.state == "99.0" + + state = hass.states.get("sensor.aemet_pressure") + assert state.state == "1004.4" + + state = hass.states.get("sensor.aemet_rain") + assert state.state == "1.8" + + state = hass.states.get("sensor.aemet_rain_probability") + assert state.state == "100" + + state = hass.states.get("sensor.aemet_snow") + assert state.state == "1.8" + + state = hass.states.get("sensor.aemet_snow_probability") + assert state.state == "100" + + state = hass.states.get("sensor.aemet_station_id") + assert state.state == "3195" + + state = hass.states.get("sensor.aemet_station_name") + assert state.state == "MADRID RETIRO" + + state = hass.states.get("sensor.aemet_station_timestamp") + assert state.state == "2021-01-09T12:00:00+00:00" + + state = hass.states.get("sensor.aemet_storm_probability") + assert state.state == "0" + + state = hass.states.get("sensor.aemet_temperature") + assert state.state == "-0.7" + + state = hass.states.get("sensor.aemet_temperature_feeling") + assert state.state == "-4" + + state = hass.states.get("sensor.aemet_town_id") + assert state.state == "id28065" + + state = hass.states.get("sensor.aemet_town_name") + assert state.state == "Getafe" + + state = hass.states.get("sensor.aemet_town_timestamp") + assert state.state == "2021-01-09 11:47:45+00:00" + + state = hass.states.get("sensor.aemet_wind_bearing") + assert state.state == "90.0" + + state = hass.states.get("sensor.aemet_wind_max_speed") + assert state.state == "24" + + state = hass.states.get("sensor.aemet_wind_speed") + assert state.state == "15" diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py new file mode 100644 index 00000000000..eef6107d543 --- /dev/null +++ b/tests/components/aemet/test_weather.py @@ -0,0 +1,61 @@ +"""The sensor tests for the AEMET OpenData platform.""" + +from unittest.mock import patch + +from homeassistant.components.aemet.const import ATTRIBUTION +from homeassistant.components.weather import ( + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_SNOWY, + ATTR_FORECAST, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, +) +from homeassistant.const import ATTR_ATTRIBUTION +import homeassistant.util.dt as dt_util + +from .util import async_init_integration + + +async def test_aemet_weather(hass): + """Test states of the weather.""" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ): + await async_init_integration(hass) + + state = hass.states.get("weather.aemet_daily") + assert state + assert state.state == ATTR_CONDITION_SNOWY + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 + assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 + assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 + assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15 + forecast = state.attributes.get(ATTR_FORECAST)[0] + assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY + assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None + assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 + assert forecast.get(ATTR_FORECAST_TEMP) == 4 + assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert forecast.get(ATTR_FORECAST_TIME) == dt_util.parse_datetime( + "2021-01-10 00:00:00+00:00" + ) + assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20 + + state = hass.states.get("weather.aemet_hourly") + assert state is None diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py new file mode 100644 index 00000000000..991e7459bf6 --- /dev/null +++ b/tests/components/aemet/util.py @@ -0,0 +1,93 @@ +"""Tests for the AEMET OpenData integration.""" + +import requests_mock + +from homeassistant.components.aemet import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +def aemet_requests_mock(mock): + """Mock requests performed to AEMET OpenData API.""" + + station_3195_fixture = "aemet/station-3195.json" + station_3195_data_fixture = "aemet/station-3195-data.json" + station_list_fixture = "aemet/station-list.json" + station_list_data_fixture = "aemet/station-list-data.json" + + town_28065_forecast_daily_fixture = "aemet/town-28065-forecast-daily.json" + town_28065_forecast_daily_data_fixture = "aemet/town-28065-forecast-daily-data.json" + town_28065_forecast_hourly_fixture = "aemet/town-28065-forecast-hourly.json" + town_28065_forecast_hourly_data_fixture = ( + "aemet/town-28065-forecast-hourly-data.json" + ) + town_id28065_fixture = "aemet/town-id28065.json" + town_list_fixture = "aemet/town-list.json" + + mock.get( + "https://opendata.aemet.es/opendata/api/observacion/convencional/datos/estacion/3195", + text=load_fixture(station_3195_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/sh/208c3ca3", + text=load_fixture(station_3195_data_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/api/observacion/convencional/todas", + text=load_fixture(station_list_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/sh/2c55192f", + text=load_fixture(station_list_data_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/28065", + text=load_fixture(town_28065_forecast_daily_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/sh/64e29abb", + text=load_fixture(town_28065_forecast_daily_data_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/horaria/28065", + text=load_fixture(town_28065_forecast_hourly_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/sh/18ca1886", + text=load_fixture(town_28065_forecast_hourly_data_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/api/maestro/municipio/id28065", + text=load_fixture(town_id28065_fixture), + ) + mock.get( + "https://opendata.aemet.es/opendata/api/maestro/municipios", + text=load_fixture(town_list_fixture), + ) + + +async def async_init_integration( + hass: HomeAssistant, + skip_setup: bool = False, +): + """Set up the AEMET OpenData integration in Home Assistant.""" + + with requests_mock.mock() as _m: + aemet_requests_mock(_m) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "mock", + CONF_LATITUDE: "40.30403754", + CONF_LONGITUDE: "-3.72935236", + CONF_NAME: "AEMET", + }, + ) + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 4e550d94b09..248abaf6b5f 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -1,14 +1,22 @@ """Define tests for the AirVisual config flow.""" from unittest.mock import patch -from pyairvisual.errors import InvalidKeyError, NodeProError +from pyairvisual.errors import ( + AirVisualError, + InvalidKeyError, + NodeProError, + NotFoundError, +) from homeassistant import data_entry_flow -from homeassistant.components.airvisual import ( +from homeassistant.components.airvisual.const import ( + CONF_CITY, + CONF_COUNTRY, CONF_GEOGRAPHIES, CONF_INTEGRATION_TYPE, DOMAIN, - INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, INTEGRATION_TYPE_NODE_PRO, ) from homeassistant.config_entries import SOURCE_USER @@ -19,6 +27,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_PASSWORD, CONF_SHOW_ON_MAP, + CONF_STATE, ) from homeassistant.setup import async_setup_component @@ -38,7 +47,9 @@ async def test_duplicate_error(hass): ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={"type": "Geographical Location"} + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=geography_conf @@ -64,14 +75,8 @@ async def test_duplicate_error(hass): assert result["reason"] == "already_configured" -async def test_invalid_identifier(hass): - """Test that an invalid API key or Node/Pro ID throws an error.""" - geography_conf = { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - } - +async def test_invalid_identifier_geography_api_key(hass): + """Test that an invalid API key throws an error.""" with patch( "pyairvisual.air_quality.AirQuality.nearest_city", side_effect=InvalidKeyError, @@ -79,64 +84,73 @@ async def test_invalid_identifier(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={"type": "Geographical Location"}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=geography_conf + result["flow_id"], + user_input={ + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} -async def test_migration(hass): - """Test migrating from version 1 to the current version.""" - conf = { - CONF_API_KEY: "abcde12345", - CONF_GEOGRAPHIES: [ - {CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}, - {CONF_LATITUDE: 35.48847, CONF_LONGITUDE: 137.5263065}, - ], - } - - config_entry = MockConfigEntry( - domain=DOMAIN, version=1, unique_id="abcde12345", data=conf - ) - config_entry.add_to_hass(hass) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - with patch("pyairvisual.air_quality.AirQuality.nearest_city"), patch.object( - hass.config_entries, "async_forward_entry_setup" +async def test_invalid_identifier_geography_name(hass): + """Test that an invalid location name throws an error.""" + with patch( + "pyairvisual.air_quality.AirQuality.city", + side_effect=NotFoundError, ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: conf}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + }, + ) - config_entries = hass.config_entries.async_entries(DOMAIN) - - assert len(config_entries) == 2 - - assert config_entries[0].unique_id == "51.528308, -0.3817765" - assert config_entries[0].title == "Cloud API (51.528308, -0.3817765)" - assert config_entries[0].data == { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, - } - - assert config_entries[1].unique_id == "35.48847, 137.5263065" - assert config_entries[1].title == "Cloud API (35.48847, 137.5263065)" - assert config_entries[1].data == { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 35.48847, - CONF_LONGITUDE: 137.5263065, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, - } + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_CITY: "location_not_found"} -async def test_node_pro_error(hass): - """Test that an invalid Node/Pro ID shows an error.""" +async def test_invalid_identifier_geography_unknown(hass): + """Test that an unknown identifier issue throws an error.""" + with patch( + "pyairvisual.air_quality.AirQuality.city", + side_effect=AirVisualError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_invalid_identifier_node_pro(hass): + """Test that an invalid Node/Pro identifier shows an error.""" node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"} with patch( @@ -153,6 +167,53 @@ async def test_node_pro_error(hass): assert result["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} +async def test_migration(hass): + """Test migrating from version 1 to the current version.""" + conf = { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [ + {CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}, + {CONF_CITY: "Beijing", CONF_STATE: "Beijing", CONF_COUNTRY: "China"}, + ], + } + + config_entry = MockConfigEntry( + domain=DOMAIN, version=1, unique_id="abcde12345", data=conf + ) + config_entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + with patch("pyairvisual.air_quality.AirQuality.city"), patch( + "pyairvisual.air_quality.AirQuality.nearest_city" + ), patch.object(hass.config_entries, "async_forward_entry_setup"): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: conf}) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + assert len(config_entries) == 2 + + assert config_entries[0].unique_id == "51.528308, -0.3817765" + assert config_entries[0].title == "Cloud API (51.528308, -0.3817765)" + assert config_entries[0].data == { + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, + } + + assert config_entries[1].unique_id == "Beijing, Beijing, China" + assert config_entries[1].title == "Cloud API (Beijing, Beijing, China)" + assert config_entries[1].data == { + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_NAME, + } + + async def test_options_flow(hass): """Test config flow options.""" geography_conf = { @@ -186,8 +247,8 @@ async def test_options_flow(hass): assert config_entry.options == {CONF_SHOW_ON_MAP: False} -async def test_step_geography(hass): - """Test the geograph (cloud API) step.""" +async def test_step_geography_by_coords(hass): + """Test setting up a geopgraphy entry by latitude/longitude.""" conf = { CONF_API_KEY: "abcde12345", CONF_LATITUDE: 51.528308, @@ -200,7 +261,7 @@ async def test_step_geography(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={"type": "Geographical Location"}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=conf @@ -212,7 +273,39 @@ async def test_step_geography(hass): CONF_API_KEY: "abcde12345", CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, + } + + +async def test_step_geography_by_name(hass): + """Test setting up a geopgraphy entry by city/state/country.""" + conf = { + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + } + + with patch( + "homeassistant.components.airvisual.async_setup_entry", return_value=True + ), patch("pyairvisual.air_quality.AirQuality.city"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (Beijing, Beijing, China)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_NAME, } @@ -244,18 +337,19 @@ async def test_step_node_pro(hass): async def test_step_reauth(hass): """Test that the reauth step works.""" - geography_conf = { + entry_data = { CONF_API_KEY: "abcde12345", CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, } MockConfigEntry( - domain=DOMAIN, unique_id="51.528308, -0.3817765", data=geography_conf + domain=DOMAIN, unique_id="51.528308, -0.3817765", data=entry_data ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "reauth"}, data=geography_conf + DOMAIN, context={"source": "reauth"}, data=entry_data ) assert result["step_id"] == "reauth_confirm" @@ -287,11 +381,20 @@ async def test_step_user(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "geography" + assert result["step_id"] == "geography_by_coords" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "geography_by_name" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 82432bc37ab..56316026c9a 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -230,31 +230,28 @@ async def test_if_fires_on_state_change(hass, calls): ) # Fake that the entity is armed home. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_PENDING) hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME) await hass.async_block_till_done() assert len(calls) == 3 assert ( calls[2].data["some"] - == "armed_home - device - alarm_control_panel.entity - pending - armed_home - None" + == "armed_home - device - alarm_control_panel.entity - disarmed - armed_home - None" ) # Fake that the entity is armed away. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_PENDING) hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() assert len(calls) == 4 assert ( calls[3].data["some"] - == "armed_away - device - alarm_control_panel.entity - pending - armed_away - None" + == "armed_away - device - alarm_control_panel.entity - armed_home - armed_away - None" ) # Fake that the entity is armed night. - hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_PENDING) hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT) await hass.async_block_till_done() assert len(calls) == 5 assert ( calls[4].data["some"] - == "armed_night - device - alarm_control_panel.entity - pending - armed_night - None" + == "armed_night - device - alarm_control_panel.entity - armed_away - armed_night - None" ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 0bdbac70d7d..cd013ca70d9 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -323,6 +323,7 @@ async def test_report_fan_speed_state(hass): "friendly_name": "Off fan", "speed": "off", "supported_features": 1, + "percentage": 0, "speed_list": ["off", "low", "medium", "high"], }, ) @@ -333,6 +334,7 @@ async def test_report_fan_speed_state(hass): "friendly_name": "Low speed fan", "speed": "low", "supported_features": 1, + "percentage": 33, "speed_list": ["off", "low", "medium", "high"], }, ) @@ -343,6 +345,7 @@ async def test_report_fan_speed_state(hass): "friendly_name": "Medium speed fan", "speed": "medium", "supported_features": 1, + "percentage": 66, "speed_list": ["off", "low", "medium", "high"], }, ) @@ -353,6 +356,7 @@ async def test_report_fan_speed_state(hass): "friendly_name": "High speed fan", "speed": "high", "supported_features": 1, + "percentage": 100, "speed_list": ["off", "low", "medium", "high"], }, ) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 05a60c86ae0..c018e07c264 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -26,7 +26,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.core import Context, callback +from homeassistant.core import Context from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -42,17 +42,13 @@ from . import ( reported_properties, ) -from tests.common import async_mock_service +from tests.common import async_capture_events, async_mock_service @pytest.fixture def events(hass): """Fixture that catches alexa events.""" - events = [] - hass.bus.async_listen( - smart_home.EVENT_ALEXA_SMART_HOME, callback(lambda e: events.append(e)) - ) - yield events + return async_capture_events(hass, smart_home.EVENT_ALEXA_SMART_HOME) @pytest.fixture @@ -383,6 +379,7 @@ async def test_variable_fan(hass): "supported_features": 1, "speed_list": ["low", "medium", "high"], "speed": "high", + "percentage": 100, }, ) appliance = await discovery_test(device, hass) @@ -423,82 +420,82 @@ async def test_variable_fan(hass): "Alexa.PercentageController", "SetPercentage", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"percentage": "50"}, ) - assert call.data["speed"] == "medium" + assert call.data["percentage"] == 50 call, _ = await assert_request_calls_service( "Alexa.PercentageController", "SetPercentage", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"percentage": "33"}, ) - assert call.data["speed"] == "low" + assert call.data["percentage"] == 33 call, _ = await assert_request_calls_service( "Alexa.PercentageController", "SetPercentage", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"percentage": "100"}, ) - assert call.data["speed"] == "high" + assert call.data["percentage"] == 100 await assert_percentage_changes( hass, - [("high", "-5"), ("off", "5"), ("low", "-80"), ("medium", "-34")], + [(95, "-5"), (100, "5"), (20, "-80"), (66, "-34")], "Alexa.PercentageController", "AdjustPercentage", "fan#test_2", "percentageDelta", - "fan.set_speed", - "speed", + "fan.set_percentage", + "percentage", ) call, _ = await assert_request_calls_service( "Alexa.PowerLevelController", "SetPowerLevel", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"powerLevel": "20"}, ) - assert call.data["speed"] == "low" + assert call.data["percentage"] == 20 call, _ = await assert_request_calls_service( "Alexa.PowerLevelController", "SetPowerLevel", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"powerLevel": "50"}, ) - assert call.data["speed"] == "medium" + assert call.data["percentage"] == 50 call, _ = await assert_request_calls_service( "Alexa.PowerLevelController", "SetPowerLevel", "fan#test_2", - "fan.set_speed", + "fan.set_percentage", hass, payload={"powerLevel": "99"}, ) - assert call.data["speed"] == "high" + assert call.data["percentage"] == 99 await assert_percentage_changes( hass, - [("high", "-5"), ("medium", "-50"), ("low", "-80")], + [(95, "-5"), (50, "-50"), (20, "-80")], "Alexa.PowerLevelController", "AdjustPowerLevel", "fan#test_2", "powerLevelDelta", - "fan.set_speed", - "speed", + "fan.set_percentage", + "percentage", ) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index a057eada531..2cbf8636d79 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -175,6 +175,22 @@ async def test_doorbell_event(hass, aioclient_mock): assert call_json["event"]["payload"]["cause"]["type"] == "PHYSICAL_INTERACTION" assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_doorbell" + hass.states.async_set( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "on", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 2 + async def test_proactive_mode_filter_states(hass, aioclient_mock): """Test all the cases that filter states.""" diff --git a/tests/components/ambiclimate/test_config_flow.py b/tests/components/ambiclimate/test_config_flow.py index b87c2171815..3a325490064 100644 --- a/tests/components/ambiclimate/test_config_flow.py +++ b/tests/components/ambiclimate/test_config_flow.py @@ -5,6 +5,7 @@ import ambiclimate from homeassistant import data_entry_flow from homeassistant.components.ambiclimate import config_flow +from homeassistant.config import async_process_ha_core_config from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.setup import async_setup_component from homeassistant.util import aiohttp @@ -12,9 +13,11 @@ from homeassistant.util import aiohttp async def init_config_flow(hass): """Init a configuration flow.""" - await async_setup_component( - hass, "http", {"http": {"base_url": "https://hass.com"}} + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, ) + await async_setup_component(hass, "http", {}) config_flow.register_flow_implementation(hass, "id", "secret") flow = config_flow.AmbiclimateFlowHandler() @@ -58,20 +61,20 @@ async def test_full_flow_implementation(hass): assert result["step_id"] == "auth" assert ( result["description_placeholders"]["cb_url"] - == "https://hass.com/api/ambiclimate" + == "https://example.com/api/ambiclimate" ) url = result["description_placeholders"]["authorization_url"] assert "https://api.ambiclimate.com/oauth2/authorize" in url assert "client_id=id" in url assert "response_type=code" in url - assert "redirect_uri=https%3A%2F%2Fhass.com%2Fapi%2Fambiclimate" in url + assert "redirect_uri=https%3A%2F%2Fexample.com%2Fapi%2Fambiclimate" in url with patch("ambiclimate.AmbiclimateOAuth.get_access_token", return_value="test"): result = await flow.async_step_code("123ABC") assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Ambiclimate" - assert result["data"]["callback_url"] == "https://hass.com/api/ambiclimate" + assert result["data"]["callback_url"] == "https://example.com/api/ambiclimate" assert result["data"][CONF_CLIENT_SECRET] == "secret" assert result["data"][CONF_CLIENT_ID] == "id" diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 0f2cfaf2893..0cae565f7bb 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -7,7 +7,6 @@ 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, @@ -55,7 +54,13 @@ async def test_get_triggers(hass, device_reg, entity_reg): }, ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert_lists_same(triggers, expected_triggers) + + # Test triggers are either arcam_fmj specific or media_player entity triggers + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + for expected_trigger in expected_triggers: + assert expected_trigger in triggers + for trigger in triggers: + assert trigger in expected_triggers or trigger["domain"] == "media_player" async def test_if_fires_on_turn_on_request(hass, calls, player_setup, state): diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py new file mode 100644 index 00000000000..7faec5d336c --- /dev/null +++ b/tests/components/asuswrt/test_config_flow.py @@ -0,0 +1,296 @@ +"""Tests for the AsusWrt config flow.""" +from socket import gaierror +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.asuswrt.const import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + CONF_TRACK_UNKNOWN, + DOMAIN, +) +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry + +HOST = "myrouter.asuswrt.com" +IP_ADDRESS = "192.168.1.1" +SSH_KEY = "1234" + +CONFIG_DATA = { + CONF_HOST: HOST, + CONF_PORT: 22, + CONF_PROTOCOL: "telnet", + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", + CONF_MODE: "ap", +} + + +@pytest.fixture(name="connect") +def mock_controller_connect(): + """Mock a successful connection.""" + with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: + service_mock.return_value.connection.async_connect = AsyncMock() + service_mock.return_value.is_connected = True + service_mock.return_value.connection.disconnect = AsyncMock() + yield service_mock + + +async def test_user(hass, connect): + """Test user config.""" + 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" + + # test with all provided + with patch( + "homeassistant.components.asuswrt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", + return_value=IP_ADDRESS, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass, connect): + """Test import step.""" + with patch( + "homeassistant.components.asuswrt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", + return_value=IP_ADDRESS, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONFIG_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_ssh(hass, connect): + """Test import step with ssh file.""" + config_data = CONFIG_DATA.copy() + config_data.pop(CONF_PASSWORD) + config_data[CONF_SSH_KEY] = SSH_KEY + + with patch( + "homeassistant.components.asuswrt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", + return_value=IP_ADDRESS, + ), patch( + "homeassistant.components.asuswrt.config_flow.os.path.isfile", + return_value=True, + ), patch( + "homeassistant.components.asuswrt.config_flow.os.access", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config_data, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == config_data + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_error_no_password_ssh(hass): + """Test we abort if component is already setup.""" + config_data = CONFIG_DATA.copy() + config_data.pop(CONF_PASSWORD) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "pwd_or_ssh"} + + +async def test_error_both_password_ssh(hass): + """Test we abort if component is already setup.""" + config_data = CONFIG_DATA.copy() + config_data[CONF_SSH_KEY] = SSH_KEY + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "pwd_and_ssh"} + + +async def test_error_invalid_ssh(hass): + """Test we abort if component is already setup.""" + config_data = CONFIG_DATA.copy() + config_data.pop(CONF_PASSWORD) + config_data[CONF_SSH_KEY] = SSH_KEY + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "ssh_not_file"} + + +async def test_error_invalid_host(hass): + """Test we abort if host name is invalid.""" + with patch( + "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", + side_effect=gaierror, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_host"} + + +async def test_abort_if_already_setup(hass): + """Test we abort if component is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", + return_value=IP_ADDRESS, + ): + # Should fail, same HOST (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + # Should fail, same HOST (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONFIG_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_on_connect_failed(hass): + """Test when we have errors connecting the router.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch("homeassistant.components.asuswrt.router.AsusWrt") as asus_wrt: + asus_wrt.return_value.connection.async_connect = AsyncMock() + asus_wrt.return_value.is_connected = False + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.asuswrt.router.AsusWrt") as asus_wrt: + asus_wrt.return_value.connection.async_connect = AsyncMock(side_effect=OSError) + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.asuswrt.router.AsusWrt") as asus_wrt: + asus_wrt.return_value.connection.async_connect = AsyncMock( + side_effect=TypeError + ) + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_options_flow(hass): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + options={CONF_REQUIRE_IP: True}, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.asuswrt.async_setup_entry", return_value=True): + 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_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + CONF_REQUIRE_IP: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_CONSIDER_HOME] == 20 + assert config_entry.options[CONF_TRACK_UNKNOWN] is True + assert config_entry.options[CONF_INTERFACE] == "aaa" + assert config_entry.options[CONF_DNSMASQ] == "bbb" + assert config_entry.options[CONF_REQUIRE_IP] is False diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py deleted file mode 100644 index 941b0c340d6..00000000000 --- a/tests/components/asuswrt/test_device_tracker.py +++ /dev/null @@ -1,119 +0,0 @@ -"""The tests for the ASUSWRT device tracker platform.""" - -from unittest.mock import AsyncMock, patch - -from homeassistant.components.asuswrt import ( - CONF_DNSMASQ, - CONF_INTERFACE, - DATA_ASUSWRT, - DOMAIN, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.setup import async_setup_component - - -async def test_password_or_pub_key_required(hass): - """Test creating an AsusWRT scanner without a pass or pubkey.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().is_connected = False - result = await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}} - ) - assert not result - - -async def test_network_unreachable(hass): - """Test creating an AsusWRT scanner without a pass or pubkey.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock(side_effect=OSError) - AsusWrt().is_connected = False - result = await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}} - ) - assert result - assert hass.data.get(DATA_ASUSWRT) is None - - -async def test_get_scanner_with_password_no_pubkey(hass): - """Test creating an AsusWRT scanner with a password and no pubkey.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().connection.async_get_connected_devices = AsyncMock(return_value={}) - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "4321", - CONF_DNSMASQ: "/", - } - }, - ) - assert result - assert hass.data[DATA_ASUSWRT] is not None - - -async def test_specify_non_directory_path_for_dnsmasq(hass): - """Test creating an AsusWRT scanner with a dnsmasq location which is not a valid directory.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().is_connected = False - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "4321", - CONF_DNSMASQ: 1234, - } - }, - ) - assert not result - - -async def test_interface(hass): - """Test creating an AsusWRT scanner using interface eth1.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().connection.async_get_connected_devices = AsyncMock(return_value={}) - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "4321", - CONF_DNSMASQ: "/", - CONF_INTERFACE: "eth1", - } - }, - ) - assert result - assert hass.data[DATA_ASUSWRT] is not None - - -async def test_no_interface(hass): - """Test creating an AsusWRT scanner using no interface.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().is_connected = False - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "4321", - CONF_DNSMASQ: "/", - CONF_INTERFACE: None, - } - }, - ) - assert not result diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 69c70c409d5..994111370fd 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,71 +1,150 @@ -"""The tests for the AsusWrt sensor platform.""" - +"""Tests for the AsusWrt sensor.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch from aioasuswrt.asuswrt import Device +import pytest -from homeassistant.components import sensor -from homeassistant.components.asuswrt import ( - CONF_DNSMASQ, - CONF_INTERFACE, +from homeassistant.components import device_tracker, sensor +from homeassistant.components.asuswrt.const import DOMAIN +from homeassistant.components.asuswrt.sensor import _SensorTypes +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME +from homeassistant.const import ( + CONF_HOST, CONF_MODE, + CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, - CONF_SENSORS, - DOMAIN, + CONF_USERNAME, + STATE_HOME, + STATE_NOT_HOME, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -VALID_CONFIG_ROUTER_SSH = { - DOMAIN: { - CONF_DNSMASQ: "/", - CONF_HOST: "fake_host", - CONF_INTERFACE: "eth0", - CONF_MODE: "router", - CONF_PORT: "22", - CONF_PROTOCOL: "ssh", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_SENSORS: [ - "devices", - "download_speed", - "download", - "upload_speed", - "upload", - ], - } +from tests.common import MockConfigEntry, async_fire_time_changed + +HOST = "myrouter.asuswrt.com" +IP_ADDRESS = "192.168.1.1" + +CONFIG_DATA = { + CONF_HOST: HOST, + CONF_PORT: 22, + CONF_PROTOCOL: "ssh", + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", + CONF_MODE: "router", } MOCK_DEVICES = { "a1:b1:c1:d1:e1:f1": Device("a1:b1:c1:d1:e1:f1", "192.168.1.2", "Test"), "a2:b2:c2:d2:e2:f2": Device("a2:b2:c2:d2:e2:f2", "192.168.1.3", "TestTwo"), - "a3:b3:c3:d3:e3:f3": Device("a3:b3:c3:d3:e3:f3", "192.168.1.4", "TestThree"), } MOCK_BYTES_TOTAL = [60000000000, 50000000000] MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] -async def test_sensors(hass: HomeAssistant, mock_device_tracker_conf): - """Test creating an AsusWRT sensor.""" - with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: - AsusWrt().connection.async_connect = AsyncMock() - AsusWrt().async_get_connected_devices = AsyncMock(return_value=MOCK_DEVICES) - AsusWrt().async_get_bytes_total = AsyncMock(return_value=MOCK_BYTES_TOTAL) - AsusWrt().async_get_current_transfer_rates = AsyncMock( +@pytest.fixture(name="connect") +def mock_controller_connect(): + """Mock a successful connection.""" + with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: + service_mock.return_value.connection.async_connect = AsyncMock() + service_mock.return_value.is_connected = True + service_mock.return_value.connection.disconnect = AsyncMock() + service_mock.return_value.async_get_connected_devices = AsyncMock( + return_value=MOCK_DEVICES + ) + service_mock.return_value.async_get_bytes_total = AsyncMock( + return_value=MOCK_BYTES_TOTAL + ) + service_mock.return_value.async_get_current_transfer_rates = AsyncMock( return_value=MOCK_CURRENT_TRANSFER_RATES ) + yield service_mock - assert await async_setup_component(hass, DOMAIN, VALID_CONFIG_ROUTER_SSH) - await hass.async_block_till_done() - assert ( - hass.states.get(f"{sensor.DOMAIN}.asuswrt_devices_connected").state == "3" - ) - assert ( - hass.states.get(f"{sensor.DOMAIN}.asuswrt_download_speed").state == "160.0" - ) - assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_download").state == "60.0" - assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_upload_speed").state == "80.0" - assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_upload").state == "50.0" +async def test_sensors(hass, connect): + """Test creating an AsusWRT sensor.""" + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + # Pre-enable the status sensor + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {_SensorTypes(_SensorTypes.DEVICES).sensor_name}", + suggested_object_id="asuswrt_connected_devices", + disabled_by=None, + ) + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {_SensorTypes(_SensorTypes.DOWNLOAD_SPEED).sensor_name}", + suggested_object_id="asuswrt_download_speed", + disabled_by=None, + ) + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {_SensorTypes(_SensorTypes.DOWNLOAD).sensor_name}", + suggested_object_id="asuswrt_download", + disabled_by=None, + ) + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {_SensorTypes(_SensorTypes.UPLOAD_SPEED).sensor_name}", + suggested_object_id="asuswrt_upload_speed", + disabled_by=None, + ) + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {_SensorTypes(_SensorTypes.UPLOAD).sensor_name}", + suggested_object_id="asuswrt_upload", + disabled_by=None, + ) + + # init config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + options={CONF_CONSIDER_HOME: 60}, + ) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(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"{device_tracker.DOMAIN}.test").state == STATE_HOME + assert hass.states.get(f"{device_tracker.DOMAIN}.testtwo").state == STATE_HOME + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_connected_devices").state == "2" + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_download_speed").state == "160.0" + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_download").state == "60.0" + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_upload_speed").state == "80.0" + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_upload").state == "50.0" + + # add one device and remove another + MOCK_DEVICES.pop("a1:b1:c1:d1:e1:f1") + MOCK_DEVICES["a3:b3:c3:d3:e3:f3"] = Device( + "a3:b3:c3:d3:e3:f3", "192.168.1.4", "TestThree" + ) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + # consider home option set, all devices still home + assert hass.states.get(f"{device_tracker.DOMAIN}.test").state == STATE_HOME + assert hass.states.get(f"{device_tracker.DOMAIN}.testtwo").state == STATE_HOME + assert hass.states.get(f"{device_tracker.DOMAIN}.testthree").state == STATE_HOME + assert hass.states.get(f"{sensor.DOMAIN}.asuswrt_connected_devices").state == "2" + + hass.config_entries.async_update_entry( + config_entry, options={CONF_CONSIDER_HOME: 0} + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + # consider home option not set, device "test" not home + assert hass.states.get(f"{device_tracker.DOMAIN}.test").state == STATE_NOT_HOME diff --git a/tests/components/aurora/test_config_flow.py b/tests/components/aurora/test_config_flow.py index b9e0496f668..d9d0c4fd128 100644 --- a/tests/components/aurora/test_config_flow.py +++ b/tests/components/aurora/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from aiohttp import ClientError + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.aurora.const import DOMAIN @@ -55,7 +57,7 @@ async def test_form_cannot_connect(hass): with patch( "homeassistant.components.aurora.AuroraForecast.get_forecast_data", - side_effect=ConnectionError, + side_effect=ClientError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 2c9a39c6fb6..207667fc26d 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.auth import InvalidAuthError from homeassistant.auth.models import Credentials from homeassistant.components import auth from homeassistant.components.auth import RESULT_TYPE_USER @@ -13,6 +14,24 @@ from . import async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser +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") + + credential = Credentials( + id="mock-credential-id", + auth_provider_type="insecure_example", + auth_provider_id=None, + data={"username": "test-user"}, + is_new=False, + ) + user.credentials.append(credential) + + return await hass.auth.async_create_refresh_token( + user, CLIENT_ID, credential=credential + ) + + async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): """Test logging in with new user and refreshing tokens.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) @@ -107,12 +126,6 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): refresh_token = await hass.auth.async_validate_access_token(hass_access_token) user = refresh_token.user - credential = Credentials( - auth_provider_type="homeassistant", auth_provider_id=None, data={}, id="test-id" - ) - user.credentials.append(credential) - assert len(user.credentials) == 1 - client = await hass_ws_client(hass, hass_access_token) await client.send_json({"id": 5, "type": auth.WS_TYPE_CURRENT_USER}) @@ -185,8 +198,7 @@ async def test_refresh_token_system_generated(hass, aiohttp_client): async def test_refresh_token_different_client_id(hass, aiohttp_client): """Test that we verify client ID.""" client = await async_setup_auth(hass, aiohttp_client) - user = await hass.auth.async_create_user("Test User") - refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + refresh_token = await async_setup_user_refresh_token(hass) # No client ID resp = await client.post( @@ -229,11 +241,37 @@ async def test_refresh_token_different_client_id(hass, aiohttp_client): ) +async def test_refresh_token_provider_rejected( + hass, aiohttp_client, hass_admin_user, hass_admin_credential +): + """Test that we verify client ID.""" + client = await async_setup_auth(hass, aiohttp_client) + refresh_token = await async_setup_user_refresh_token(hass) + + # Rejected by provider + with patch( + "homeassistant.auth.providers.insecure_example.ExampleAuthProvider.async_validate_refresh_token", + side_effect=InvalidAuthError("Invalid access"), + ): + resp = await client.post( + "/auth/token", + data={ + "client_id": CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token.token, + }, + ) + + assert resp.status == 403 + result = await resp.json() + assert result["error"] == "access_denied" + assert result["error_description"] == "Invalid access" + + async def test_revoking_refresh_token(hass, aiohttp_client): """Test that we can revoke refresh tokens.""" client = await async_setup_auth(hass, aiohttp_client) - user = await hass.auth.async_create_user("Test User") - refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + refresh_token = await async_setup_user_refresh_token(hass) # Test that we can create an access token resp = await client.post( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index c31af555e32..91531481a99 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,5 +1,6 @@ """The tests for the automation component.""" import asyncio +import logging from unittest.mock import Mock, patch import pytest @@ -29,7 +30,12 @@ from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, async_mock_service, mock_restore_cache +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, + mock_restore_cache, +) from tests.components.logbook.test_init import MockLazyEventPartialState @@ -152,7 +158,7 @@ async def test_two_triggers(hass, calls): assert len(calls) == 2 -async def test_trigger_service_ignoring_condition(hass, calls): +async def test_trigger_service_ignoring_condition(hass, caplog, calls): """Test triggers.""" assert await async_setup_component( hass, @@ -162,19 +168,25 @@ async def test_trigger_service_ignoring_condition(hass, calls): "alias": "test", "trigger": [{"platform": "event", "event_type": "test_event"}], "condition": { - "condition": "state", + "condition": "numeric_state", "entity_id": "non.existing", - "state": "beer", + "above": "1", }, "action": {"service": "test.automation"}, } }, ) + caplog.clear() + caplog.set_level(logging.WARNING) + hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.WARNING + await hass.services.async_call( "automation", "trigger", {"entity_id": "automation.test"}, blocking=True ) @@ -489,10 +501,7 @@ async def test_reload_config_service(hass, calls, hass_admin_user, hass_read_onl assert len(calls) == 1 assert calls[0].data.get("event") == "test_event" - test_reload_event = [] - hass.bus.async_listen( - EVENT_AUTOMATION_RELOADED, lambda event: test_reload_event.append(event) - ) + test_reload_event = async_capture_events(hass, EVENT_AUTOMATION_RELOADED) with patch( "homeassistant.config.load_yaml_config_file", @@ -1235,6 +1244,94 @@ async def test_automation_variables(hass, caplog): assert len(calls) == 3 +async def test_automation_trigger_variables(hass, caplog): + """Test automation trigger variables.""" + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "variables": { + "event_type": "{{ trigger.event.event_type }}", + }, + "trigger_variables": { + "test_var": "defined_in_config", + }, + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": { + "service": "test.automation", + "data": { + "value": "{{ test_var }}", + "event_type": "{{ event_type }}", + }, + }, + }, + { + "variables": { + "event_type": "{{ trigger.event.event_type }}", + "test_var": "overridden_in_config", + }, + "trigger_variables": { + "test_var": "defined_in_config", + }, + "trigger": {"platform": "event", "event_type": "test_event_2"}, + "action": { + "service": "test.automation", + "data": { + "value": "{{ test_var }}", + "event_type": "{{ event_type }}", + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["value"] == "defined_in_config" + assert calls[0].data["event_type"] == "test_event" + + hass.bus.async_fire("test_event_2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["value"] == "overridden_in_config" + assert calls[1].data["event_type"] == "test_event_2" + + assert "Error rendering variables" not in caplog.text + + +async def test_automation_bad_trigger_variables(hass, caplog): + """Test automation trigger variables accessing hass is rejected.""" + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger_variables": { + "test_var": "{{ states('foo.bar') }}", + }, + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": { + "service": "test.automation", + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event") + assert "Use of 'states' is not supported in limited templates" in caplog.text + + await hass.async_block_till_done() + assert len(calls) == 0 + + async def test_blueprint_automation(hass, calls): """Test blueprint automation.""" assert await async_setup_component( diff --git a/tests/components/binary_sensor/test_significant_change.py b/tests/components/binary_sensor/test_significant_change.py new file mode 100644 index 00000000000..673374a15e4 --- /dev/null +++ b/tests/components/binary_sensor/test_significant_change.py @@ -0,0 +1,20 @@ +"""Test the Binary Sensor significant change platform.""" +from homeassistant.components.binary_sensor.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change(): + """Detect Binary Sensor significant changes.""" + old_attrs = {"attr_1": "value_1"} + new_attrs = {"attr_1": "value_2"} + + assert ( + async_check_significant_change(None, "on", old_attrs, "on", old_attrs) is False + ) + assert ( + async_check_significant_change(None, "on", old_attrs, "on", new_attrs) is False + ) + assert ( + async_check_significant_change(None, "on", old_attrs, "off", old_attrs) is True + ) diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 52433f2f58f..d56978deb27 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -5,10 +5,9 @@ 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_REGION, CONF_USE_LOCATION, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from tests.common import MockConfigEntry diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 9aaaf9a249d..061dc23797e 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -3,7 +3,7 @@ from asyncio import TimeoutError as AsyncIOTimeoutError from contextlib import nullcontext from datetime import timedelta from typing import Any, Dict, Optional -from unittest.mock import patch +from unittest.mock import MagicMock, patch from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN @@ -29,13 +29,19 @@ async def setup_bond_entity( patch_version=False, patch_device_ids=False, patch_platforms=False, + patch_bridge=False, + patch_token=False, ): """Set up Bond entity.""" config_entry.add_to_hass(hass) - with patch_bond_version(enabled=patch_version), patch_bond_device_ids( + with patch_start_bpup(), patch_bond_bridge(enabled=patch_bridge), patch_bond_token( + enabled=patch_token + ), patch_bond_version(enabled=patch_version), patch_bond_device_ids( enabled=patch_device_ids - ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( + ), patch_setup_entry( + "cover", enabled=patch_platforms + ), patch_setup_entry( "fan", enabled=patch_platforms ), patch_setup_entry( "light", enabled=patch_platforms @@ -54,6 +60,8 @@ async def setup_platform( bond_version: Dict[str, Any] = None, props: Dict[str, Any] = None, state: Dict[str, Any] = None, + bridge: Dict[str, Any] = None, + token: Dict[str, Any] = None, ): """Set up the specified Bond platform.""" mock_entry = MockConfigEntry( @@ -63,9 +71,11 @@ async def setup_platform( mock_entry.add_to_hass(hass) with patch("homeassistant.components.bond.PLATFORMS", [platform]): - with patch_bond_version(return_value=bond_version), patch_bond_device_ids( + with patch_bond_version(return_value=bond_version), patch_bond_bridge( + return_value=bridge + ), patch_bond_token(return_value=token), patch_bond_device_ids( return_value=[bond_device_id] - ), patch_bond_device( + ), patch_start_bpup(), patch_bond_device( return_value=discovered_device ), patch_bond_device_properties( return_value=props @@ -95,6 +105,44 @@ def patch_bond_version( ) +def patch_bond_bridge( + enabled: bool = True, return_value: Optional[dict] = None, side_effect=None +): + """Patch Bond API bridge endpoint.""" + if not enabled: + return nullcontext() + + if return_value is None: + return_value = { + "name": "bond-name", + "location": "bond-location", + "bluelight": 127, + } + + return patch( + "homeassistant.components.bond.Bond.bridge", + return_value=return_value, + side_effect=side_effect, + ) + + +def patch_bond_token( + enabled: bool = True, return_value: Optional[dict] = None, side_effect=None +): + """Patch Bond API token endpoint.""" + if not enabled: + return nullcontext() + + if return_value is None: + return_value = {"locked": 1} + + return patch( + "homeassistant.components.bond.Bond.token", + return_value=return_value, + side_effect=side_effect, + ) + + def patch_bond_device_ids(enabled: bool = True, return_value=None, side_effect=None): """Patch Bond API devices endpoint.""" if not enabled: @@ -118,6 +166,14 @@ def patch_bond_device(return_value=None): ) +def patch_start_bpup(): + """Patch start_bpup.""" + return patch( + "homeassistant.components.bond.start_bpup", + return_value=MagicMock(), + ) + + def patch_bond_action(): """Patch Bond API action endpoint.""" return patch("homeassistant.components.bond.Bond.action") diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index dba6c590641..39fd1a2db5d 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -8,7 +8,14 @@ from homeassistant import config_entries, core, setup from homeassistant.components.bond.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from .common import patch_bond_device_ids, patch_bond_version +from .common import ( + patch_bond_bridge, + patch_bond_device, + patch_bond_device_ids, + patch_bond_device_properties, + patch_bond_token, + patch_bond_version, +) from tests.common import MockConfigEntry @@ -24,7 +31,9 @@ async def test_user_form(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "test-bond-id"} - ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_device_ids( + return_value=["f6776c11", "f6776c12"] + ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -32,7 +41,43 @@ async def test_user_form(hass: core.HomeAssistant): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "test-bond-id" + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_form_with_non_bridge(hass: core.HomeAssistant): + """Test setup a smart by bond fan.""" + 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"] == "form" + assert result["errors"] == {} + + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids( + return_value=["f6776c11"] + ), patch_bond_device_properties(), patch_bond_device( + return_value={ + "name": "New Fan", + } + ), patch_bond_bridge( + return_value={} + ), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "New Fan" assert result2["data"] == { CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", @@ -49,7 +94,7 @@ async def test_user_form_invalid_auth(hass: core.HomeAssistant): with patch_bond_version( return_value={"bond_id": "test-bond-id"} - ), patch_bond_device_ids( + ), patch_bond_bridge(), patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=401), ): result2 = await hass.config_entries.flow.async_configure( @@ -69,7 +114,7 @@ async def test_user_form_cannot_connect(hass: core.HomeAssistant): with patch_bond_version( side_effect=ClientConnectionError() - ), patch_bond_device_ids(): + ), patch_bond_bridge(), patch_bond_device_ids(): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -87,7 +132,7 @@ async def test_user_form_old_firmware(hass: core.HomeAssistant): with patch_bond_version( return_value={"no_bond_id": "present"} - ), patch_bond_device_ids(): + ), patch_bond_bridge(), patch_bond_device_ids(): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -133,7 +178,7 @@ async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "already-registered-bond-id"} - ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_bridge(), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -160,7 +205,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "test-bond-id"} - ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_bridge(), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_ACCESS_TOKEN: "test-token"}, @@ -168,7 +213,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "test-bond-id" + assert result2["title"] == "bond-name" assert result2["data"] == { CONF_HOST: "test-host", CONF_ACCESS_TOKEN: "test-token", @@ -177,6 +222,70 @@ async def test_zeroconf_form(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): + """Test we get the discovery form and we handle the token being unavailable.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch_bond_version(), patch_bond_token(): + 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"}, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"] == {} + + with patch_bond_version(), patch_bond_bridge(), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "test-token", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): + """Test we get the discovery form when we can get the token.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch_bond_version(return_value={"bondid": "test-bond-id"}), patch_bond_token( + return_value={"token": "discovered-token"} + ), patch_bond_bridge( + return_value={"name": "discovered-name"} + ), patch_bond_device_ids(): + 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"}, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"] == {} + + with _patch_async_setup() as mock_setup, _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"] == "discovered-name" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "discovered-token", + } + assert len(mock_setup.mock_calls) == 1 + 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.""" await setup.async_setup_component(hass, "persistent_notification", {}) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 1adea282a30..49a6e4a5b68 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -41,12 +41,17 @@ def ceiling_fan(name: str): async def turn_fan_on( - hass: core.HomeAssistant, fan_id: str, speed: Optional[str] = None + hass: core.HomeAssistant, + fan_id: str, + speed: Optional[str] = None, + percentage: Optional[int] = None, ) -> None: """Turn the fan on at the specified speed.""" service_data = {ATTR_ENTITY_ID: fan_id} if speed: service_data[fan.ATTR_SPEED] = speed + if percentage: + service_data[fan.ATTR_PERCENTAGE] = percentage await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, @@ -93,13 +98,13 @@ async def test_non_standard_speed_list(hass: core.HomeAssistant): with patch_bond_action() as mock_set_speed_low: await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) mock_set_speed_low.assert_called_once_with( - "test-device-id", Action.set_speed(1) + "test-device-id", Action.set_speed(2) ) with patch_bond_action() as mock_set_speed_medium: await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) mock_set_speed_medium.assert_called_once_with( - "test-device-id", Action.set_speed(3) + "test-device-id", Action.set_speed(4) ) with patch_bond_action() as mock_set_speed_high: @@ -135,6 +140,58 @@ async def test_turn_on_fan_with_speed(hass: core.HomeAssistant): mock_set_speed.assert_called_with("test-device-id", Action.set_speed(1)) +async def test_turn_on_fan_with_percentage_3_speeds(hass: core.HomeAssistant): + """Tests that turn on command delegates to set speed API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=10) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(1)) + + mock_set_speed.reset_mock() + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=50) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(2)) + + mock_set_speed.reset_mock() + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=100) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(3)) + + +async def test_turn_on_fan_with_percentage_6_speeds(hass: core.HomeAssistant): + """Tests that turn on command delegates to set speed API.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + props={"max_speed": 6}, + ) + + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=10) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(1)) + + mock_set_speed.reset_mock() + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=50) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(3)) + + mock_set_speed.reset_mock() + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", percentage=100) + + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(6)) + + async def test_turn_on_fan_without_speed(hass: core.HomeAssistant): """Tests that turn on command delegates to turn on API.""" await setup_platform( diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 98d86058c49..7346acc5276 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -1,5 +1,8 @@ """Tests for the Bond module.""" -from aiohttp import ClientConnectionError +from unittest.mock import Mock + +from aiohttp import ClientConnectionError, ClientResponseError +from bond_api import DeviceType from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ( @@ -12,7 +15,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .common import patch_bond_version, patch_setup_entry, setup_bond_entity +from .common import ( + patch_bond_bridge, + patch_bond_device, + patch_bond_device_ids, + patch_bond_device_properties, + patch_bond_device_state, + patch_bond_version, + patch_setup_entry, + patch_start_bpup, + setup_bond_entity, +) from tests.common import MockConfigEntry @@ -44,25 +57,22 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch_bond_version( + with patch_bond_bridge(), patch_bond_version( return_value={ "bondid": "test-bond-id", "target": "test-model", "fw_ver": "test-version", } - ): - with patch_setup_entry( - "cover" - ) as mock_cover_async_setup_entry, patch_setup_entry( - "fan" - ) as mock_fan_async_setup_entry, patch_setup_entry( - "light" - ) as mock_light_async_setup_entry, patch_setup_entry( - "switch" - ) as mock_switch_async_setup_entry: - result = await setup_bond_entity(hass, config_entry, patch_device_ids=True) - assert result is True - await hass.async_block_till_done() + ), patch_setup_entry("cover") as mock_cover_async_setup_entry, patch_setup_entry( + "fan" + ) as mock_fan_async_setup_entry, patch_setup_entry( + "light" + ) as mock_light_async_setup_entry, patch_setup_entry( + "switch" + ) as mock_switch_async_setup_entry: + result = await setup_bond_entity(hass, config_entry, patch_device_ids=True) + assert result is True + await hass.async_block_till_done() assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state == ENTRY_STATE_LOADED @@ -71,7 +81,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss # verify hub device is registered correctly device_registry = await dr.async_get_registry(hass) hub = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) - assert hub.name == "test-bond-id" + assert hub.name == "bond-name" assert hub.manufacturer == "Olibra" assert hub.model == "test-model" assert hub.sw_version == "test-version" @@ -96,6 +106,7 @@ async def test_unload_config_entry(hass: HomeAssistant): patch_version=True, patch_device_ids=True, patch_platforms=True, + patch_bridge=True, ) assert result is True await hass.async_block_till_done() @@ -105,3 +116,141 @@ async def test_unload_config_entry(hass: HomeAssistant): assert config_entry.entry_id not in hass.data[DOMAIN] assert config_entry.state == ENTRY_STATE_NOT_LOADED + + +async def test_old_identifiers_are_removed(hass: HomeAssistant): + """Test we remove the old non-unique identifiers.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + old_identifers = (DOMAIN, "device_id") + new_identifiers = (DOMAIN, "test-bond-id", "device_id") + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={old_identifers}, + manufacturer="any", + name="old", + ) + + config_entry.add_to_hass(hass) + + with patch_bond_bridge(), patch_bond_version( + return_value={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + } + ), patch_start_bpup(), patch_bond_device_ids( + return_value=["bond-device-id", "device_id"] + ), patch_bond_device( + return_value={ + "name": "test1", + "type": DeviceType.GENERIC_DEVICE, + } + ), patch_bond_device_properties( + return_value={} + ), patch_bond_device_state( + return_value={} + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.entry_id in hass.data[DOMAIN] + assert config_entry.state == ENTRY_STATE_LOADED + assert config_entry.unique_id == "test-bond-id" + + # verify the device info is cleaned up + assert device_registry.async_get_device(identifiers={old_identifers}) is None + assert device_registry.async_get_device(identifiers={new_identifiers}) is not None + + +async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant): + """Test we can setup a smart by bond device and get the suggested area.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + config_entry.add_to_hass(hass) + + with patch_bond_bridge( + side_effect=ClientResponseError(Mock(), Mock(), status=404) + ), patch_bond_version( + return_value={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + } + ), patch_start_bpup(), patch_bond_device_ids( + return_value=["bond-device-id", "device_id"] + ), patch_bond_device( + return_value={ + "name": "test1", + "type": DeviceType.GENERIC_DEVICE, + "location": "Den", + } + ), patch_bond_device_properties( + return_value={} + ), patch_bond_device_state( + return_value={} + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.entry_id in hass.data[DOMAIN] + assert config_entry.state == ENTRY_STATE_LOADED + assert config_entry.unique_id == "test-bond-id" + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + assert device is not None + assert device.suggested_area == "Den" + + +async def test_bridge_device_suggested_area(hass: HomeAssistant): + """Test we can setup a bridge bond device and get the suggested area.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + config_entry.add_to_hass(hass) + + with patch_bond_bridge( + return_value={ + "name": "Office Bridge", + "location": "Office", + } + ), patch_bond_version( + return_value={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + } + ), patch_start_bpup(), patch_bond_device_ids( + return_value=["bond-device-id", "device_id"] + ), patch_bond_device( + return_value={ + "name": "test1", + "type": DeviceType.GENERIC_DEVICE, + "location": "Bathroom", + } + ), patch_bond_device_properties( + return_value={} + ), patch_bond_device_state( + return_value={} + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.entry_id in hass.data[DOMAIN] + assert config_entry.state == ENTRY_STATE_LOADED + assert config_entry.unique_id == "test-bond-id" + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + assert device is not None + assert device.suggested_area == "Office" diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6d871187e26..59d051fbe86 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -29,6 +29,15 @@ from .common import ( from tests.common import async_fire_time_changed +def light(name: str): + """Create a light with a given name.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF, Action.SET_BRIGHTNESS], + } + + def ceiling_fan(name: str): """Create a ceiling fan (that has built-in light) with given name.""" return { @@ -47,6 +56,24 @@ def dimmable_ceiling_fan(name: str): } +def down_light_ceiling_fan(name: str): + """Create a ceiling fan (that has built-in down light) with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_DOWN_LIGHT_ON, Action.TURN_DOWN_LIGHT_OFF], + } + + +def up_light_ceiling_fan(name: str): + """Create a ceiling fan (that has built-in down light) with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_UP_LIGHT_ON, Action.TURN_UP_LIGHT_OFF], + } + + def fireplace(name: str): """Create a fireplace with given name.""" return { @@ -85,6 +112,36 @@ async def test_fan_entity_registry(hass: core.HomeAssistant): assert entity.unique_id == "test-hub-id_test-device-id" +async def test_fan_up_light_entity_registry(hass: core.HomeAssistant): + """Tests that fan with up light devices are registered in the entity registry.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + up_light_ceiling_fan("fan-name"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + entity = registry.entities["light.fan_name_up_light"] + assert entity.unique_id == "test-hub-id_test-device-id_up_light" + + +async def test_fan_down_light_entity_registry(hass: core.HomeAssistant): + """Tests that fan with down light devices are registered in the entity registry.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + down_light_ceiling_fan("fan-name"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + entity = registry.entities["light.fan_name_down_light"] + assert entity.unique_id == "test-hub-id_test-device-id_down_light" + + async def test_fireplace_entity_registry(hass: core.HomeAssistant): """Tests that flame fireplace devices are registered in the entity registry.""" await setup_platform( @@ -113,10 +170,25 @@ async def test_fireplace_with_light_entity_registry(hass: core.HomeAssistant): registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() entity_flame = registry.entities["light.fireplace_name"] assert entity_flame.unique_id == "test-hub-id_test-device-id" - entity_light = registry.entities["light.fireplace_name_2"] + entity_light = registry.entities["light.fireplace_name_light"] assert entity_light.unique_id == "test-hub-id_test-device-id_light" +async def test_light_entity_registry(hass: core.HomeAssistant): + """Tests lights are registered in the entity registry.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("light-name"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + entity = registry.entities["light.light_name"] + assert entity.unique_id == "test-hub-id_test-device-id" + + async def test_sbb_trust_state(hass: core.HomeAssistant): """Assumed state should be False if device is a Smart by Bond.""" version = { @@ -124,7 +196,7 @@ async def test_sbb_trust_state(hass: core.HomeAssistant): "bondid": "test-bond-id", } await setup_platform( - hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version, bridge={} ) device = hass.states.get("light.name_1") @@ -245,6 +317,98 @@ async def test_turn_on_light_with_brightness(hass: core.HomeAssistant): ) +async def test_turn_on_up_light(hass: core.HomeAssistant): + """Tests that turn on command, on an up light, delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + up_light_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1_up_light"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_on.assert_called_once_with( + "test-device-id", Action(Action.TURN_UP_LIGHT_ON) + ) + + +async def test_turn_off_up_light(hass: core.HomeAssistant): + """Tests that turn off command, on an up light, delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + up_light_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.name_1_up_light"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_off.assert_called_once_with( + "test-device-id", Action(Action.TURN_UP_LIGHT_OFF) + ) + + +async def test_turn_on_down_light(hass: core.HomeAssistant): + """Tests that turn on command, on a down light, delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + down_light_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1_down_light"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_on.assert_called_once_with( + "test-device-id", Action(Action.TURN_DOWN_LIGHT_ON) + ) + + +async def test_turn_off_down_light(hass: core.HomeAssistant): + """Tests that turn off command, on a down light, delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + down_light_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.name_1_down_light"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_off.assert_called_once_with( + "test-device-id", Action(Action.TURN_DOWN_LIGHT_OFF) + ) + + async def test_update_reports_light_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is on.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) @@ -267,6 +431,50 @@ async def test_update_reports_light_is_off(hass: core.HomeAssistant): assert hass.states.get("light.name_1").state == "off" +async def test_update_reports_up_light_is_on(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports the up light is on.""" + await setup_platform(hass, LIGHT_DOMAIN, up_light_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"up_light": 1, "light": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1_up_light").state == "on" + + +async def test_update_reports_up_light_is_off(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports the up light is off.""" + await setup_platform(hass, LIGHT_DOMAIN, up_light_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"up_light": 0, "light": 0}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1_up_light").state == "off" + + +async def test_update_reports_down_light_is_on(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports the down light is on.""" + await setup_platform(hass, LIGHT_DOMAIN, down_light_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"down_light": 1, "light": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1_down_light").state == "on" + + +async def test_update_reports_down_light_is_off(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports the down light is off.""" + await setup_platform(hass, LIGHT_DOMAIN, down_light_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"down_light": 0, "light": 0}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1_down_light").state == "off" + + async def test_turn_on_fireplace_with_brightness(hass: core.HomeAssistant): """Tests that turn on command delegates to set flame API.""" await setup_platform( diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 3e380b44de4..d8c6a44a3ea 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -774,3 +774,32 @@ async def test_event_rrule_hourly_ended(mock_now, hass, calendar): state = hass.states.get("calendar.private") assert state.name == calendar.name assert state.state == STATE_OFF + + +async def test_get_events(hass, calendar): + """Test that all events are returned on API.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + entity = hass.data["calendar"].get_entity("calendar.private") + events = await entity.async_get_events( + hass, datetime.date(2015, 11, 27), datetime.date(2015, 11, 28) + ) + assert len(events) == 14 + + +async def test_get_events_custom_calendars(hass, calendar): + """Test that only searched events are returned on API.""" + config = dict(CALDAV_CONFIG) + config["custom_calendars"] = [ + {"name": "Private", "calendar": "Private", "search": "This is a normal event"} + ] + + assert await async_setup_component(hass, "calendar", {"calendar": config}) + await hass.async_block_till_done() + + entity = hass.data["calendar"].get_entity("calendar.private_private") + events = await entity.async_get_events( + hass, datetime.date(2015, 11, 27), datetime.date(2015, 11, 28) + ) + assert len(events) == 1 + assert events[0]["summary"] == "This is a normal event" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 2c2d744deb9..340a4b5d756 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -155,25 +155,20 @@ async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera): async def test_websocket_stream_no_source( hass, hass_ws_client, mock_camera, mock_stream ): - """Test camera/stream websocket command.""" + """Test camera/stream websocket command with camera with no source.""" await async_setup_component(hass, "camera", {}) - with patch( - "homeassistant.components.camera.request_stream", - return_value="http://home.assistant/playlist.m3u8", - ) as mock_request_stream: - # Request playlist through WebSocket - client = await hass_ws_client(hass) - await client.send_json( - {"id": 6, "type": "camera/stream", "entity_id": "camera.demo_camera"} - ) - msg = await client.receive_json() + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json( + {"id": 6, "type": "camera/stream", "entity_id": "camera.demo_camera"} + ) + msg = await client.receive_json() - # Assert WebSocket response - assert not mock_request_stream.called - assert msg["id"] == 6 - assert msg["type"] == TYPE_RESULT - assert not msg["success"] + # Assert WebSocket response + assert msg["id"] == 6 + assert msg["type"] == TYPE_RESULT + assert not msg["success"] async def test_websocket_camera_stream(hass, hass_ws_client, mock_camera, mock_stream): @@ -181,9 +176,9 @@ async def test_websocket_camera_stream(hass, hass_ws_client, mock_camera, mock_s await async_setup_component(hass, "camera", {}) with patch( - "homeassistant.components.camera.request_stream", + "homeassistant.components.camera.Stream.endpoint_url", return_value="http://home.assistant/playlist.m3u8", - ) as mock_request_stream, patch( + ) as mock_stream_view_url, patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="http://example.com", ): @@ -195,7 +190,7 @@ async def test_websocket_camera_stream(hass, hass_ws_client, mock_camera, mock_s msg = await client.receive_json() # Assert WebSocket response - assert mock_request_stream.called + assert mock_stream_view_url.called assert msg["id"] == 6 assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -248,9 +243,7 @@ async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): ATTR_ENTITY_ID: "camera.demo_camera", camera.ATTR_MEDIA_PLAYER: "media_player.test", } - with patch("homeassistant.components.camera.request_stream"), pytest.raises( - HomeAssistantError - ): + with pytest.raises(HomeAssistantError): # Call service await hass.services.async_call( camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True @@ -265,7 +258,7 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream): ) await async_setup_component(hass, "media_player", {}) with patch( - "homeassistant.components.camera.request_stream" + "homeassistant.components.camera.Stream.endpoint_url", ) as mock_request_stream, patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="http://example.com", @@ -289,7 +282,7 @@ async def test_no_preload_stream(hass, mock_stream): """Test camera preload preference.""" demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: False}) with patch( - "homeassistant.components.camera.request_stream" + "homeassistant.components.camera.Stream.endpoint_url", ) as mock_request_stream, patch( "homeassistant.components.camera.prefs.CameraPreferences.get", return_value=demo_prefs, @@ -308,8 +301,8 @@ async def test_preload_stream(hass, mock_stream): """Test camera preload preference.""" demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: True}) with patch( - "homeassistant.components.camera.request_stream" - ) as mock_request_stream, patch( + "homeassistant.components.camera.create_stream" + ) as mock_create_stream, patch( "homeassistant.components.camera.prefs.CameraPreferences.get", return_value=demo_prefs, ), patch( @@ -322,7 +315,7 @@ async def test_preload_stream(hass, mock_stream): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - assert mock_request_stream.called + assert mock_create_stream.called async def test_record_service_invalid_path(hass, mock_camera): @@ -348,10 +341,9 @@ async def test_record_service(hass, mock_camera, mock_stream): "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="http://example.com", ), patch( - "homeassistant.components.stream.async_handle_record_service", - ) as mock_record_service, patch.object( - hass.config, "is_allowed_path", return_value=True - ): + "homeassistant.components.stream.Stream.async_record", + autospec=True, + ) as mock_record: # Call service await hass.services.async_call( camera.DOMAIN, @@ -361,4 +353,4 @@ async def test_record_service(hass, mock_camera, mock_stream): ) # So long as we call stream.record, the rest should be covered # by those tests. - assert mock_record_service.called + assert mock_record.called diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 050d6a6932d..be24afcb538 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -11,6 +11,19 @@ import pytest from homeassistant.components import tts from homeassistant.components.cast import media_player as cast from homeassistant.components.cast.media_player import ChromecastInfo +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, +) from homeassistant.config import async_process_ha_core_config from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import PlatformNotReady @@ -662,6 +675,17 @@ async def test_entity_cast_status(hass: HomeAssistantType): assert state.state == "unknown" assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.attributes.get("supported_features") == ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | 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 @@ -680,6 +704,21 @@ async def test_entity_cast_status(hass: HomeAssistantType): assert state.attributes.get("volume_level") == 0.2 assert state.attributes.get("is_volume_muted") + # Disable support for volume control + cast_status = MagicMock() + cast_status.volume_control_type = "fixed" + cast_status_cb(cast_status) + 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 + ) + async def test_entity_play_media(hass: HomeAssistantType): """Test playing media.""" @@ -894,6 +933,17 @@ async def test_entity_control(hass: HomeAssistantType): assert state.state == "unknown" assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.attributes.get("supported_features") == ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + ) + # Turn on await common.async_turn_on(hass, entity_id) chromecast.play_media.assert_called_once_with( @@ -940,6 +990,21 @@ async def test_entity_control(hass: HomeAssistantType): media_status_cb(media_status) 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_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SEEK + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + ) + # Media previous await common.async_media_previous_track(hass, entity_id) chromecast.media_controller.queue_prev.assert_called_once_with() diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index ea31ba50ea0..1c62782107b 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -5,7 +5,12 @@ from unittest.mock import patch from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -94,4 +99,9 @@ async def test_unload_config_entry(mock_now, hass): assert entry.state == ENTRY_STATE_NOT_LOADED state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("sensor.cert_expiry_timestamp_example_com") assert state is None diff --git a/tests/components/climacell/__init__.py b/tests/components/climacell/__init__.py new file mode 100644 index 00000000000..04ebc3c14c3 --- /dev/null +++ b/tests/components/climacell/__init__.py @@ -0,0 +1 @@ +"""Tests for the ClimaCell Weather API integration.""" diff --git a/tests/components/climacell/conftest.py b/tests/components/climacell/conftest.py new file mode 100644 index 00000000000..3666243b4b4 --- /dev/null +++ b/tests/components/climacell/conftest.py @@ -0,0 +1,42 @@ +"""Configure py.test.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(name="climacell_config_flow_connect", autouse=True) +def climacell_config_flow_connect(): + """Mock valid climacell config flow setup.""" + with patch( + "homeassistant.components.climacell.config_flow.ClimaCell.realtime", + return_value={}, + ): + yield + + +@pytest.fixture(name="climacell_config_entry_update") +def climacell_config_entry_update_fixture(): + """Mock valid climacell config entry setup.""" + with patch( + "homeassistant.components.climacell.ClimaCell.realtime", + return_value={}, + ), patch( + "homeassistant.components.climacell.ClimaCell.forecast_hourly", + return_value=[], + ), patch( + "homeassistant.components.climacell.ClimaCell.forecast_daily", + return_value=[], + ), patch( + "homeassistant.components.climacell.ClimaCell.forecast_nowcast", + return_value=[], + ): + yield diff --git a/tests/components/climacell/const.py b/tests/components/climacell/const.py new file mode 100644 index 00000000000..ada0ebd1eb5 --- /dev/null +++ b/tests/components/climacell/const.py @@ -0,0 +1,9 @@ +"""Constants for climacell tests.""" + +from homeassistant.const import CONF_API_KEY + +API_KEY = "aa" + +MIN_CONFIG = { + CONF_API_KEY: API_KEY, +} diff --git a/tests/components/climacell/test_config_flow.py b/tests/components/climacell/test_config_flow.py new file mode 100644 index 00000000000..a34bf6fd0fd --- /dev/null +++ b/tests/components/climacell/test_config_flow.py @@ -0,0 +1,167 @@ +"""Test the ClimaCell config flow.""" +import logging +from unittest.mock import patch + +from pyclimacell.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant import data_entry_flow +from homeassistant.components.climacell.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.climacell.const import ( + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers.typing import HomeAssistantType + +from .const import API_KEY, MIN_CONFIG + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def test_user_flow_minimum_fields(hass: HomeAssistantType) -> None: + """Test user config flow with minimum fields.""" + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_NAME] == DEFAULT_NAME + assert result["data"][CONF_API_KEY] == API_KEY + assert result["data"][CONF_LATITUDE] == hass.config.latitude + assert result["data"][CONF_LONGITUDE] == hass.config.longitude + + +async def test_user_flow_same_unique_ids(hass: HomeAssistantType) -> None: + """Test user config flow with the same unique ID as an existing entry.""" + user_input = _get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG) + MockConfigEntry( + domain=DOMAIN, + data=user_input, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_input), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_cannot_connect(hass: HomeAssistantType) -> None: + """Test user config flow when ClimaCell can't connect.""" + with patch( + "homeassistant.components.climacell.config_flow.ClimaCell.realtime", + side_effect=CantConnectException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_flow_invalid_api(hass: HomeAssistantType) -> None: + """Test user config flow when API key is invalid.""" + with patch( + "homeassistant.components.climacell.config_flow.ClimaCell.realtime", + side_effect=InvalidAPIKeyException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_user_flow_rate_limited(hass: HomeAssistantType) -> None: + """Test user config flow when API key is rate limited.""" + with patch( + "homeassistant.components.climacell.config_flow.ClimaCell.realtime", + side_effect=RateLimitedException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "rate_limited"} + + +async def test_user_flow_unknown_exception(hass: HomeAssistantType) -> None: + """Test user config flow when unknown error occurs.""" + with patch( + "homeassistant.components.climacell.config_flow.ClimaCell.realtime", + side_effect=UnknownException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_options_flow(hass: HomeAssistantType) -> None: + """Test options config flow for climacell.""" + user_config = _get_config_schema(hass)(MIN_CONFIG) + entry = MockConfigEntry( + domain=DOMAIN, + data=user_config, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_config), + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.options[CONF_TIMESTEP] == DEFAULT_TIMESTEP + assert CONF_TIMESTEP not in entry.data + + result = await hass.config_entries.options.async_init(entry.entry_id, data=None) + + 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_TIMESTEP: 1} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "" + assert result["data"][CONF_TIMESTEP] == 1 + assert entry.options[CONF_TIMESTEP] == 1 diff --git a/tests/components/climacell/test_init.py b/tests/components/climacell/test_init.py new file mode 100644 index 00000000000..1456a068d77 --- /dev/null +++ b/tests/components/climacell/test_init.py @@ -0,0 +1,82 @@ +"""Tests for Climacell init.""" +from datetime import timedelta +import logging +from unittest.mock import patch + +import pytest + +from homeassistant.components.climacell.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.climacell.const import DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util + +from .const import MIN_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) + + +async def test_load_and_unload( + hass: HomeAssistantType, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test loading and unloading entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=_get_config_schema(hass)(MIN_CONFIG), + unique_id=_get_unique_id(hass, _get_config_schema(hass)(MIN_CONFIG)), + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 + + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + + +async def test_update_interval( + hass: HomeAssistantType, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test that update_interval changes based on number of entries.""" + now = dt_util.utcnow() + async_fire_time_changed(hass, now) + config = _get_config_schema(hass)(MIN_CONFIG) + for i in range(1, 3): + config_entry = MockConfigEntry( + domain=DOMAIN, data=config, unique_id=_get_unique_id(hass, config) + str(i) + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patch("homeassistant.components.climacell.ClimaCell.realtime") as mock_api: + # First entry refresh will happen in 7 minutes due to original update interval. + # Next refresh for this entry will happen at 20 minutes due to the update interval + # change. + mock_api.return_value = {} + async_fire_time_changed(hass, now + timedelta(minutes=7)) + await hass.async_block_till_done() + assert mock_api.call_count == 1 + + # Second entry refresh will happen in 13 minutes due to the update interval set + # when it was set up. Next refresh for this entry will happen at 26 minutes due to the + # update interval change. + mock_api.reset_mock() + async_fire_time_changed(hass, now + timedelta(minutes=13)) + await hass.async_block_till_done() + assert not mock_api.call_count == 1 + + # 19 minutes should be after the first update for each config entry and before the + # second update for the first config entry + mock_api.reset_mock() + async_fire_time_changed(hass, now + timedelta(minutes=19)) + await hass.async_block_till_done() + assert not mock_api.call_count == 0 diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 4755d470418..75276a9f2e2 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -43,7 +43,20 @@ def mock_cloud_login(hass, mock_cloud_setup): hass.data[const.DOMAIN].id_token = jwt.encode( { "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", + "custom:sub-exp": "2300-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + + +@pytest.fixture +def mock_expired_cloud_login(hass, mock_cloud_setup): + """Mock cloud is logged in.""" + hass.data[const.DOMAIN].id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-01", "cognito:username": "abcdefghjkl", }, "test", diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 966ef4b0af3..8e104f641b2 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -215,3 +215,16 @@ async def test_alexa_update_report_state(hass, cloud_prefs): await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 + + +def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs): + """Test that alexa config enabled requires a valid Cloud sub.""" + assert cloud_prefs.alexa_enabled + assert hass.data["cloud"].is_logged_in + assert hass.data["cloud"].subscription_expired + + config = alexa_config.AlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] + ) + + assert not config.enabled diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index f58ea1a415b..e1da6bbe0a8 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -192,3 +192,16 @@ async def test_google_config_expose_entity_prefs(mock_conf, cloud_prefs): google_default_expose=["sensor"], ) assert not mock_conf.should_expose(state) + + +def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs): + """Test that google config enabled requires a valid Cloud sub.""" + assert cloud_prefs.google_enabled + assert hass.data["cloud"].is_logged_in + assert hass.data["cloud"].subscription_expired + + config = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] + ) + + assert not config.enabled diff --git a/tests/components/coinmarketcap/__init__.py b/tests/components/coinmarketcap/__init__.py deleted file mode 100644 index 9e9b871bbe2..00000000000 --- a/tests/components/coinmarketcap/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the coinmarketcap component.""" diff --git a/tests/components/coinmarketcap/test_sensor.py b/tests/components/coinmarketcap/test_sensor.py deleted file mode 100644 index 369a006f568..00000000000 --- a/tests/components/coinmarketcap/test_sensor.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tests for the CoinMarketCap sensor platform.""" -import json -from unittest.mock import patch - -import pytest - -from homeassistant.components.sensor import DOMAIN -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture - -VALID_CONFIG = { - DOMAIN: { - "platform": "coinmarketcap", - "currency_id": 1027, - "display_currency": "EUR", - "display_currency_decimals": 3, - } -} - - -@pytest.fixture -async def setup_sensor(hass): - """Set up demo sensor component.""" - with assert_setup_component(1, DOMAIN): - with patch( - "coinmarketcap.Market.ticker", - return_value=json.loads(load_fixture("coinmarketcap.json")), - ): - await async_setup_component(hass, DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - - -async def test_setup(hass, setup_sensor): - """Test the setup with custom settings.""" - state = hass.states.get("sensor.ethereum") - assert state is not None - - assert state.name == "Ethereum" - assert state.state == "493.455" - assert state.attributes.get("symbol") == "ETH" - assert state.attributes.get("unit_of_measurement") == "EUR" diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index f66e16e606f..35176cc79f9 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -55,7 +55,7 @@ async def test_create_area_with_name_already_in_use(hass, client, registry): assert not msg["success"] assert msg["error"]["code"] == "invalid_info" - assert msg["error"]["message"] == "Name is already in use" + assert msg["error"]["message"] == "The name mock (mock) is already in use" assert len(registry.areas) == 1 @@ -147,5 +147,5 @@ async def test_update_area_with_name_already_in_use(hass, client, registry): assert not msg["success"] assert msg["error"]["code"] == "invalid_info" - assert msg["error"]["message"] == "Name is already in use" + assert msg["error"]["message"] == "The name mock 2 (mock2) is already in use" assert len(registry.areas) == 2 diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 2d3cfe54f5a..363910ffd72 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -48,7 +48,9 @@ async def test_list(hass, hass_ws_client, hass_admin_user): id="hij", name="Inactive User", is_active=False, groups=[group] ).add_to_hass(hass) - refresh_token = await hass.auth.async_create_refresh_token(owner, CLIENT_ID) + refresh_token = await hass.auth.async_create_refresh_token( + owner, CLIENT_ID, credential=owner.credentials[0] + ) access_token = hass.auth.async_create_access_token(refresh_token) client = await hass_ws_client(hass, access_token) @@ -60,13 +62,13 @@ async def test_list(hass, hass_ws_client, hass_admin_user): assert len(data) == 4 assert data[0] == { "id": hass_admin_user.id, - "username": None, + "username": "admin", "name": "Mock User", "is_owner": False, "is_active": True, "system_generated": False, "group_ids": [group.id for group in hass_admin_user.groups], - "credentials": [], + "credentials": [{"type": "homeassistant"}], } assert data[1] == { "id": owner.id, diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index 6af3e6507d5..0aafa93e635 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -4,24 +4,19 @@ import pytest from homeassistant.auth.providers import homeassistant as prov_ha from homeassistant.components.config import auth_provider_homeassistant as auth_ha -from tests.common import CLIENT_ID, MockUser, register_auth_provider +from tests.common import CLIENT_ID, MockUser @pytest.fixture(autouse=True) -def setup_config(hass): - """Fixture that sets up the auth provider homeassistant module.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(auth_ha.async_setup(hass)) +async def setup_config(hass, local_auth): + """Fixture that sets up the auth provider .""" + await auth_ha.async_setup(hass) @pytest.fixture -async def auth_provider(hass): +async def auth_provider(local_auth): """Hass auth provider.""" - provider = hass.auth.auth_providers[0] - await provider.async_initialize() - return provider + return local_auth @pytest.fixture @@ -34,8 +29,8 @@ async def owner_access_token(hass, hass_owner_user): @pytest.fixture -async def test_user_credential(hass, auth_provider): - """Add a test user.""" +async def hass_admin_credential(hass, auth_provider): + """Overload credentials to admin user.""" await hass.async_add_executor_job( auth_provider.data.add_auth, "test-user", "test-pass" ) @@ -124,7 +119,7 @@ async def test_create_auth(hass, hass_ws_client, hass_storage): "id": 5, "type": "config/auth_provider/homeassistant/create", "user_id": user.id, - "username": "test-user", + "username": "test-user2", "password": "test-pass", } ) @@ -135,10 +130,10 @@ async def test_create_auth(hass, hass_ws_client, hass_storage): creds = user.credentials[0] assert creds.auth_provider_type == "homeassistant" assert creds.auth_provider_id is None - assert creds.data == {"username": "test-user"} + assert creds.data == {"username": "test-user2"} assert prov_ha.STORAGE_KEY in hass_storage - entry = hass_storage[prov_ha.STORAGE_KEY]["data"]["users"][0] - assert entry["username"] == "test-user" + entry = hass_storage[prov_ha.STORAGE_KEY]["data"]["users"][1] + assert entry["username"] == "test-user2" async def test_create_auth_duplicate_username(hass, hass_ws_client, hass_storage): @@ -242,7 +237,7 @@ async def test_delete_unknown_auth(hass, hass_ws_client): { "id": 5, "type": "config/auth_provider/homeassistant/delete", - "username": "test-user", + "username": "test-user2", } ) @@ -251,12 +246,8 @@ async def test_delete_unknown_auth(hass, hass_ws_client): assert result["error"]["code"] == "auth_not_found" -async def test_change_password( - hass, hass_ws_client, hass_admin_user, auth_provider, test_user_credential -): +async def test_change_password(hass, hass_ws_client, auth_provider): """Test that change password succeeds with valid password.""" - await hass.auth.async_link_user(hass_admin_user, test_user_credential) - client = await hass_ws_client(hass) await client.send_json( { @@ -273,10 +264,9 @@ async def test_change_password( async def test_change_password_wrong_pw( - hass, hass_ws_client, hass_admin_user, auth_provider, test_user_credential + hass, hass_ws_client, hass_admin_user, auth_provider ): """Test that change password fails with invalid password.""" - await hass.auth.async_link_user(hass_admin_user, test_user_credential) client = await hass_ws_client(hass) await client.send_json( @@ -295,8 +285,9 @@ async def test_change_password_wrong_pw( await auth_provider.async_validate_login("test-user", "new-pass") -async def test_change_password_no_creds(hass, hass_ws_client): +async def test_change_password_no_creds(hass, hass_ws_client, hass_admin_user): """Test that change password fails with no credentials.""" + hass_admin_user.credentials.clear() client = await hass_ws_client(hass) await client.send_json( @@ -313,9 +304,7 @@ async def test_change_password_no_creds(hass, hass_ws_client): assert result["error"]["code"] == "credentials_not_found" -async def test_admin_change_password_not_owner( - hass, hass_ws_client, auth_provider, test_user_credential -): +async def test_admin_change_password_not_owner(hass, hass_ws_client, auth_provider): """Test that change password fails when not owner.""" client = await hass_ws_client(hass) @@ -358,6 +347,8 @@ async def test_admin_change_password_no_cred( hass, hass_ws_client, owner_access_token, hass_admin_user ): """Test that change password fails with unknown credential.""" + + hass_admin_user.credentials.clear() client = await hass_ws_client(hass, owner_access_token) await client.send_json( @@ -379,12 +370,9 @@ async def test_admin_change_password( hass_ws_client, owner_access_token, auth_provider, - test_user_credential, hass_admin_user, ): """Test that owners can change any password.""" - await hass.auth.async_link_user(hass_admin_user, test_user_credential) - client = await hass_ws_client(hass, owner_access_token) await client.send_json( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 87b1559a21b..6bb1f1885eb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -68,6 +68,12 @@ async def test_get_entries(hass, client): state=core_ce.ENTRY_STATE_LOADED, connection_class=core_ce.CONN_CLASS_ASSUMED, ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by="user", + ).add_to_hass(hass) resp = await client.get("/api/config/config_entries/entry") assert resp.status == 200 @@ -83,6 +89,7 @@ async def test_get_entries(hass, client): "connection_class": "local_poll", "supports_options": True, "supports_unload": True, + "disabled_by": None, }, { "domain": "comp2", @@ -92,6 +99,17 @@ async def test_get_entries(hass, client): "connection_class": "assumed", "supports_options": False, "supports_unload": False, + "disabled_by": None, + }, + { + "domain": "comp3", + "title": "Test 3", + "source": "bla3", + "state": "not_loaded", + "connection_class": "unknown", + "supports_options": False, + "supports_unload": False, + "disabled_by": "user", }, ] @@ -680,6 +698,25 @@ async def test_update_system_options(hass, hass_ws_client): assert entry.system_options.disable_new_entities +async def test_update_system_options_nonexisting(hass, hass_ws_client): + """Test that we can update entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/system_options/update", + "entry_id": "non_existing", + "disable_new_entities": True, + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "not_found" + + async def test_update_entry(hass, hass_ws_client): """Test that we can update entry.""" assert await async_setup_component(hass, "config", {}) @@ -722,6 +759,83 @@ async def test_update_entry_nonexisting(hass, hass_ws_client): assert response["error"]["code"] == "not_found" +async def test_disable_entry(hass, hass_ws_client): + """Test that we can disable entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry(domain="demo", state="loaded") + entry.add_to_hass(hass) + assert entry.disabled_by is None + + # Disable + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/disable", + "entry_id": entry.entry_id, + "disabled_by": "user", + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == {"require_restart": True} + assert entry.disabled_by == "user" + assert entry.state == "failed_unload" + + # Enable + await ws_client.send_json( + { + "id": 6, + "type": "config_entries/disable", + "entry_id": entry.entry_id, + "disabled_by": None, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == {"require_restart": True} + assert entry.disabled_by is None + assert entry.state == "failed_unload" + + # Enable again -> no op + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/disable", + "entry_id": entry.entry_id, + "disabled_by": None, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == {"require_restart": False} + assert entry.disabled_by is None + assert entry.state == "failed_unload" + + +async def test_disable_entry_nonexisting(hass, hass_ws_client): + """Test that we can disable entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/disable", + "entry_id": "non_existing", + "disabled_by": "user", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "not_found" + + async def test_ignore_flow(hass, hass_ws_client): """Test we can ignore a flow.""" assert await async_setup_component(hass, "config", {}) @@ -763,3 +877,22 @@ async def test_ignore_flow(hass, hass_ws_client): assert entry.source == "ignore" assert entry.unique_id == "mock-unique-id" assert entry.title == "Test Integration" + + +async def test_ignore_flow_nonexisting(hass, hass_ws_client): + """Test we can ignore a flow.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/ignore_flow", + "flow_id": "non_existing", + "title": "Test Integration", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "not_found" diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index ab054ad8223..e8bb3cdc8df 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -542,8 +542,12 @@ async def test_if_fires_on_position(hass, calls): ] }, ) + hass.states.async_set(ent.entity_id, STATE_OPEN, attributes={"current_position": 1}) hass.states.async_set( - ent.entity_id, STATE_CLOSED, attributes={"current_position": 50} + ent.entity_id, STATE_CLOSED, attributes={"current_position": 95} + ) + hass.states.async_set( + ent.entity_id, STATE_OPEN, attributes={"current_position": 50} ) await hass.async_block_till_done() assert len(calls) == 3 @@ -551,8 +555,8 @@ async def test_if_fires_on_position(hass, calls): [calls[0].data["some"], calls[1].data["some"], calls[2].data["some"]] ) == sorted( [ - "is_pos_gt_45_lt_90 - device - cover.set_position_cover - open - closed - None", - "is_pos_lt_90 - device - cover.set_position_cover - open - closed - None", + "is_pos_gt_45_lt_90 - device - cover.set_position_cover - closed - open - None", + "is_pos_lt_90 - device - cover.set_position_cover - closed - open - None", "is_pos_gt_45 - device - cover.set_position_cover - open - closed - None", ] ) @@ -666,7 +670,13 @@ async def test_if_fires_on_tilt_position(hass, calls): }, ) hass.states.async_set( - ent.entity_id, STATE_CLOSED, attributes={"current_tilt_position": 50} + ent.entity_id, STATE_OPEN, attributes={"current_tilt_position": 1} + ) + hass.states.async_set( + ent.entity_id, STATE_CLOSED, attributes={"current_tilt_position": 95} + ) + hass.states.async_set( + ent.entity_id, STATE_OPEN, attributes={"current_tilt_position": 50} ) await hass.async_block_till_done() assert len(calls) == 3 @@ -674,8 +684,8 @@ async def test_if_fires_on_tilt_position(hass, calls): [calls[0].data["some"], calls[1].data["some"], calls[2].data["some"]] ) == sorted( [ - "is_pos_gt_45_lt_90 - device - cover.set_position_cover - open - closed - None", - "is_pos_lt_90 - device - cover.set_position_cover - open - closed - None", + "is_pos_gt_45_lt_90 - device - cover.set_position_cover - closed - open - None", + "is_pos_lt_90 - device - cover.set_position_cover - closed - open - None", "is_pos_gt_45 - device - cover.set_position_cover - open - closed - None", ] ) diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 3611e30f665..70d3db4149b 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -1,12 +1,10 @@ """deCONZ binary sensor platform tests.""" from copy import deepcopy -from unittest.mock import patch from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_VIBRATION, - DOMAIN as BINARY_SENSOR_DOMAIN, ) from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, @@ -16,11 +14,14 @@ from homeassistant.components.deconz.const import ( ) from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.deconz.services import SERVICE_DEVICE_REFRESH -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.helpers.entity_registry import async_entries_for_config_entry -from homeassistant.setup import async_setup_component -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_request, + setup_deconz_integration, +) SENSORS = { "1": { @@ -63,28 +64,19 @@ SENSORS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, BINARY_SENSOR_DOMAIN, {"binary_sensor": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_binary_sensors(hass): +async def test_no_binary_sensors(hass, aioclient_mock): """Test that no sensors in deconz results in no sensor entities.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_binary_sensors(hass): +async def test_binary_sensors(hass, aioclient_mock): """Test successful creation of binary sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 3 @@ -111,15 +103,20 @@ async def test_binary_sensors(hass): await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get("binary_sensor.presence_sensor").state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_allow_clip_sensor(hass): +async def test_allow_clip_sensor(hass, aioclient_mock): """Test that CLIP sensors can be allowed.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) config_entry = await setup_deconz_integration( hass, + aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True}, get_state_response=data, ) @@ -151,9 +148,9 @@ async def test_allow_clip_sensor(hass): assert hass.states.get("binary_sensor.clip_presence_sensor").state == STATE_OFF -async def test_add_new_binary_sensor(hass): +async def test_add_new_binary_sensor(hass, aioclient_mock): """Test that adding a new binary sensor works.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 0 @@ -171,10 +168,11 @@ async def test_add_new_binary_sensor(hass): assert hass.states.get("binary_sensor.presence_sensor").state == STATE_OFF -async def test_add_new_binary_sensor_ignored(hass): +async def test_add_new_binary_sensor_ignored(hass, aioclient_mock): """Test that adding a new binary sensor is not allowed.""" config_entry = await setup_deconz_integration( hass, + aioclient_mock, options={CONF_MASTER_GATEWAY: True, CONF_ALLOW_NEW_DEVICES: False}, ) gateway = get_gateway_from_config_entry(hass, config_entry) @@ -198,16 +196,16 @@ async def test_add_new_binary_sensor_ignored(hass): len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 ) - with patch( - "pydeconz.DeconzSession.request", - return_value={ - "groups": {}, - "lights": {}, - "sensors": {"1": deepcopy(SENSORS["1"])}, - }, - ): - await hass.services.async_call(DECONZ_DOMAIN, SERVICE_DEVICE_REFRESH) - await hass.async_block_till_done() + aioclient_mock.clear_requests() + data = { + "groups": {}, + "lights": {}, + "sensors": {"1": deepcopy(SENSORS["1"])}, + } + mock_deconz_request(aioclient_mock, config_entry.data, data) + + await hass.services.async_call(DECONZ_DOMAIN, SERVICE_DEVICE_REFRESH) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 assert hass.states.get("binary_sensor.presence_sensor") diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 4d68ba2a6a7..5577a2d0414 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -1,7 +1,6 @@ """deCONZ climate platform tests.""" from copy import deepcopy -from unittest.mock import patch import pytest @@ -34,15 +33,20 @@ from homeassistant.components.deconz.climate import ( DECONZ_FAN_SMART, DECONZ_PRESET_MANUAL, ) -from homeassistant.components.deconz.const import ( - CONF_ALLOW_CLIP_SENSOR, - DOMAIN as DECONZ_DOMAIN, -) +from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR from homeassistant.components.deconz.gateway import get_gateway_from_config_entry -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_OFF -from homeassistant.setup import async_setup_component +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_OFF, + STATE_UNAVAILABLE, +) -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) SENSORS = { "1": { @@ -70,24 +74,13 @@ SENSORS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, CLIMATE_DOMAIN, {"climate": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_sensors(hass): +async def test_no_sensors(hass, aioclient_mock): """Test that no sensors in deconz results in no climate entities.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_simple_climate_device(hass): +async def test_simple_climate_device(hass, aioclient_mock): """Test successful creation of climate entities. This is a simple water heater that only supports setting temperature and on and off. @@ -99,7 +92,7 @@ async def test_simple_climate_device(hass): "battery": 59, "displayflipped": None, "heatsetpoint": 2100, - "locked": None, + "locked": True, "mountingmode": None, "offset": 0, "on": True, @@ -125,7 +118,9 @@ async def test_simple_climate_device(hass): "uniqueid": "14:b4:57:ff:fe:d5:4e:77-01-0201", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -137,6 +132,7 @@ async def test_simple_climate_device(hass): ] assert climate_thermostat.attributes["current_temperature"] == 21.0 assert climate_thermostat.attributes["temperature"] == 21.0 + assert climate_thermostat.attributes["locked"] is True assert hass.states.get("sensor.thermostat_battery_level").state == "59" # Event signals thermostat configured off @@ -169,37 +165,31 @@ async def test_simple_climate_device(hass): # Verify service calls - thermostat_device = gateway.api.sensors["0"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") # Service turn on thermostat - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/sensors/0/config", json={"on": True}) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True} # Service turn on thermostat - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_OFF}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/sensors/0/config", json={"on": False}) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_OFF}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"on": False} # Service set HVAC mode to unsupported value - with patch.object( - thermostat_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -208,11 +198,13 @@ async def test_simple_climate_device(hass): ) -async def test_climate_device_without_cooling_support(hass): +async def test_climate_device_without_cooling_support(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -275,54 +267,41 @@ async def test_climate_device_without_cooling_support(hass): # Verify service calls - thermostat_device = gateway.api.sensors["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/1/config") # Service set HVAC mode to auto - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_AUTO}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/sensors/1/config", json={"mode": "auto"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_AUTO}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"mode": "auto"} # Service set HVAC mode to heat - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/sensors/1/config", json={"mode": "heat"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"mode": "heat"} # Service set HVAC mode to off - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_OFF}, - blocking=True, - ) - set_callback.assert_called_with( - "put", "/sensors/1/config", json={"mode": "off"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_HVAC_MODE: HVAC_MODE_OFF}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"mode": "off"} # Service set HVAC mode to unsupported value - with patch.object( - thermostat_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -332,22 +311,17 @@ async def test_climate_device_without_cooling_support(hass): # Service set temperature to 20 - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 20}, - blocking=True, - ) - set_callback.assert_called_with( - "put", "/sensors/1/config", json={"heatsetpoint": 2000.0} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 20}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"heatsetpoint": 2000.0} # Service set temperature without providing temperature attribute - with patch.object( - thermostat_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -361,10 +335,17 @@ async def test_climate_device_without_cooling_support(hass): await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_climate_device_with_cooling_support(hass): +async def test_climate_device_with_cooling_support(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -394,7 +375,9 @@ async def test_climate_device_with_cooling_support(hass): "uniqueid": "00:24:46:00:00:11:6f:56-01-0201", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -426,23 +409,20 @@ async def test_climate_device_with_cooling_support(hass): # Verify service calls - thermostat_device = gateway.api.sensors["0"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") # Service set temperature to 20 - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.zen_01", ATTR_TEMPERATURE: 20}, - blocking=True, - ) - set_callback.assert_called_with( - "put", "/sensors/0/config", json={"coolsetpoint": 2000.0} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.zen_01", ATTR_TEMPERATURE: 20}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"coolsetpoint": 2000.0} -async def test_climate_device_with_fan_support(hass): +async def test_climate_device_with_fan_support(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -472,7 +452,9 @@ async def test_climate_device_with_fan_support(hass): "uniqueid": "00:24:46:00:00:11:6f:56-01-0201", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -534,39 +516,31 @@ async def test_climate_device_with_fan_support(hass): # Verify service calls - thermostat_device = gateway.api.sensors["0"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") # Service set fan mode to off - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.zen_01", ATTR_FAN_MODE: FAN_OFF}, - blocking=True, - ) - set_callback.assert_called_with( - "put", "/sensors/0/config", json={"fanmode": "off"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.zen_01", ATTR_FAN_MODE: FAN_OFF}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"fanmode": "off"} # Service set fan mode to custom deCONZ mode smart - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.zen_01", ATTR_FAN_MODE: DECONZ_FAN_SMART}, - blocking=True, - ) - set_callback.assert_called_with( - "put", "/sensors/0/config", json={"fanmode": "smart"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.zen_01", ATTR_FAN_MODE: DECONZ_FAN_SMART}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"fanmode": "smart"} # Service set fan mode to unsupported value - with patch.object( - thermostat_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -575,7 +549,7 @@ async def test_climate_device_with_fan_support(hass): ) -async def test_climate_device_with_preset(hass): +async def test_climate_device_with_preset(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -606,7 +580,9 @@ async def test_climate_device_with_preset(hass): "uniqueid": "00:24:46:00:00:11:6f:56-01-0201", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -659,41 +635,31 @@ async def test_climate_device_with_preset(hass): # Verify service calls - thermostat_device = gateway.api.sensors["0"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") # Service set preset to HASS preset - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.zen_01", ATTR_PRESET_MODE: PRESET_COMFORT}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/sensors/0/config", json={"preset": "comfort"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.zen_01", ATTR_PRESET_MODE: PRESET_COMFORT}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"preset": "comfort"} # Service set preset to custom deCONZ preset - with patch.object(thermostat_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.zen_01", ATTR_PRESET_MODE: DECONZ_PRESET_MANUAL}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/sensors/0/config", json={"preset": "manual"} - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.zen_01", ATTR_PRESET_MODE: DECONZ_PRESET_MANUAL}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"preset": "manual"} # Service set preset to unsupported value - with patch.object( - thermostat_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -702,12 +668,13 @@ async def test_climate_device_with_preset(hass): ) -async def test_clip_climate_device(hass): +async def test_clip_climate_device(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) config_entry = await setup_deconz_integration( hass, + aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True}, get_state_response=data, ) @@ -739,11 +706,13 @@ async def test_clip_climate_device(hass): assert hass.states.get("climate.clip_thermostat").state == HVAC_MODE_HEAT -async def test_verify_state_update(hass): +async def test_verify_state_update(hass, aioclient_mock): """Test that state update properly.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert hass.states.get("climate.thermostat").state == HVAC_MODE_AUTO @@ -762,9 +731,9 @@ async def test_verify_state_update(hass): assert gateway.api.sensors["1"].changed_keys == {"state", "r", "t", "on", "e", "id"} -async def test_add_new_climate_device(hass): +async def test_add_new_climate_device(hass, aioclient_mock): """Test that adding a new climate device works.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index e18418ff9ae..19f544fabc9 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -212,7 +212,7 @@ async def test_manual_configuration_after_discovery_ResponseError(hass, aioclien async def test_manual_configuration_update_configuration(hass, aioclient_mock): """Test that manual configuration can update existing config entry.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) aioclient_mock.get( pydeconz.utils.URL_DISCOVER, @@ -258,7 +258,7 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock): async def test_manual_configuration_dont_update_configuration(hass, aioclient_mock): """Test that _create_entry work and that bridgeid can be requested.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) aioclient_mock.get( pydeconz.utils.URL_DISCOVER, @@ -374,7 +374,7 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock): async def test_reauth_flow_update_configuration(hass, aioclient_mock): """Verify reauth flow can update gateway API key.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, @@ -442,9 +442,9 @@ async def test_flow_ssdp_discovery(hass, aioclient_mock): } -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 but updates with new attributes.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) with patch( "homeassistant.components.deconz.async_setup_entry", @@ -467,9 +467,9 @@ async def test_ssdp_discovery_update_configuration(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp_discovery_dont_update_configuration(hass): +async def test_ssdp_discovery_dont_update_configuration(hass, aioclient_mock): """Test if a discovered bridge has already been configured.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, @@ -486,9 +486,13 @@ async def test_ssdp_discovery_dont_update_configuration(hass): assert config_entry.data[CONF_HOST] == "1.2.3.4" -async def test_ssdp_discovery_dont_update_existing_hassio_configuration(hass): +async def test_ssdp_discovery_dont_update_existing_hassio_configuration( + hass, aioclient_mock +): """Test to ensure the SSDP discovery does not update an Hass.io entry.""" - config_entry = await setup_deconz_integration(hass, source=SOURCE_HASSIO) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, source=SOURCE_HASSIO + ) result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, @@ -543,9 +547,9 @@ async def test_flow_hassio_discovery(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_hassio_discovery_update_configuration(hass): +async def test_hassio_discovery_update_configuration(hass, aioclient_mock): """Test we can update an existing config entry.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) with patch( "homeassistant.components.deconz.async_setup_entry", @@ -571,9 +575,9 @@ async def test_hassio_discovery_update_configuration(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_hassio_discovery_dont_update_configuration(hass): +async def test_hassio_discovery_dont_update_configuration(hass, aioclient_mock): """Test we can update an existing config entry.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, @@ -590,9 +594,9 @@ async def test_hassio_discovery_dont_update_configuration(hass): assert result["reason"] == "already_configured" -async def test_option_flow(hass): +async def test_option_flow(hass, aioclient_mock): """Test config flow options.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) result = await hass.config_entries.options.async_init(config_entry.entry_id) diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py index 5314a41b315..e48d44fb61e 100644 --- a/tests/components/deconz/test_cover.py +++ b/tests/components/deconz/test_cover.py @@ -1,7 +1,6 @@ """deCONZ cover platform tests.""" from copy import deepcopy -from unittest.mock import patch from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, @@ -17,12 +16,19 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER, SERVICE_STOP_COVER_TILT, ) -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.gateway import get_gateway_from_config_entry -from homeassistant.const import ATTR_ENTITY_ID, STATE_CLOSED, STATE_OPEN -from homeassistant.setup import async_setup_component +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) COVERS = { "1": { @@ -67,28 +73,19 @@ COVERS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, COVER_DOMAIN, {"cover": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_covers(hass): +async def test_no_covers(hass, aioclient_mock): """Test that no cover entities are created.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_cover(hass): +async def test_cover(hass, aioclient_mock): """Test that all supported cover entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(COVERS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 5 @@ -114,123 +111,91 @@ async def test_cover(hass): # Verify service calls for cover - windows_covering_device = gateway.api.lights["2"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/2/state") # Service open cover - with patch.object( - windows_covering_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.window_covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/2/state", json={"open": True}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.window_covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"open": True} # Service close cover - with patch.object( - windows_covering_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.window_covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/2/state", json={"open": False}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.window_covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"open": False} # Service set cover position - with patch.object( - windows_covering_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.window_covering_device", ATTR_POSITION: 40}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/2/state", json={"lift": 60}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.window_covering_device", ATTR_POSITION: 40}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"lift": 60} # Service stop cover movement - with patch.object( - windows_covering_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.window_covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/2/state", json={"stop": True}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.window_covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"stop": True} # Verify service calls for legacy cover - level_controllable_cover_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service open cover - with patch.object( - level_controllable_cover_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": False}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[5][2] == {"on": False} # Service close cover - with patch.object( - level_controllable_cover_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": True}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[6][2] == {"on": True} # Service set cover position - with patch.object( - level_controllable_cover_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.level_controllable_cover", ATTR_POSITION: 40}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"bri": 152}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.level_controllable_cover", ATTR_POSITION: 40}, + blocking=True, + ) + assert aioclient_mock.mock_calls[7][2] == {"bri": 152} # Service stop cover movement - with patch.object( - level_controllable_cover_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"bri_inc": 0}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.level_controllable_cover"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[8][2] == {"bri_inc": 0} # Test that a reported cover position of 255 (deconz-rest-api < 2.05.73) is interpreted correctly. assert hass.states.get("cover.deconz_old_brightness_cover").state == STATE_OPEN @@ -251,10 +216,17 @@ async def test_cover(hass): await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 5 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_tilt_cover(hass): +async def test_tilt_cover(hass, aioclient_mock): """Test that tilting a cover works.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = { @@ -278,54 +250,55 @@ async def test_tilt_cover(hass): "uniqueid": "00:24:46:00:00:12:34:56-01", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) - gateway = get_gateway_from_config_entry(hass, config_entry) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) assert len(hass.states.async_all()) == 1 entity = hass.states.get("cover.covering_device") assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 100 - covering_device = gateway.api.lights["0"] + # Verify service calls for tilting cover - with patch.object(covering_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: "cover.covering_device", ATTR_TILT_POSITION: 40}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/0/state", json={"tilt": 60}) + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/0/state") - with patch.object(covering_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: "cover.covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/0/state", json={"tilt": 0}) + # Service set tilt cover - with patch.object(covering_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: "cover.covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/0/state", json={"tilt": 100}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.covering_device", ATTR_TILT_POSITION: 40}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"tilt": 60} + + # Service open tilt cover + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"tilt": 0} + + # Service close tilt cover + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"tilt": 100} # Service stop cover movement - with patch.object(covering_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_STOP_COVER_TILT, - {ATTR_ENTITY_ID: "cover.covering_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/0/state", json={"stop": True}) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: "cover.covering_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"stop": True} diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 14faf1a938c..1212d72a6ee 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -4,6 +4,7 @@ from copy import deepcopy from homeassistant.components.deconz.deconz_event import CONF_DECONZ_EVENT from homeassistant.components.deconz.gateway import get_gateway_from_config_entry +from homeassistant.const import STATE_UNAVAILABLE from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration @@ -53,11 +54,13 @@ SENSORS = { } -async def test_deconz_events(hass): +async def test_deconz_events(hass, aioclient_mock): """Test successful creation of deconz events.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 3 @@ -121,5 +124,13 @@ async def test_deconz_events(hass): await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 3 + for state in states: + assert state.state == STATE_UNAVAILABLE + assert len(gateway.events) == 0 + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 assert len(gateway.events) == 0 diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index a5399fe4796..b9d538588cd 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -44,11 +44,13 @@ SENSORS = { } -async def test_get_triggers(hass): +async def test_get_triggers(hass, aioclient_mock): """Test triggers work.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) device_id = gateway.events[0].device_id triggers = await async_get_device_automations(hass, "trigger", device_id) @@ -108,20 +110,22 @@ async def test_get_triggers(hass): assert_lists_same(triggers, expected_triggers) -async def test_helper_successful(hass): +async def test_helper_successful(hass, aioclient_mock): """Verify trigger helper.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) device_id = gateway.events[0].device_id deconz_event = device_trigger._get_deconz_event_from_device_id(hass, device_id) assert deconz_event == gateway.events[0] -async def test_helper_no_match(hass): +async def test_helper_no_match(hass, aioclient_mock): """Verify trigger helper returns None when no event could be matched.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) deconz_event = device_trigger._get_deconz_event_from_device_id(hass, "mock-id") assert deconz_event is None diff --git a/tests/components/deconz/test_fan.py b/tests/components/deconz/test_fan.py index b9c154a2791..c6acbb7f6aa 100644 --- a/tests/components/deconz/test_fan.py +++ b/tests/components/deconz/test_fan.py @@ -1,11 +1,9 @@ """deCONZ fan platform tests.""" from copy import deepcopy -from unittest.mock import patch import pytest -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.fan import ( ATTR_SPEED, @@ -18,10 +16,13 @@ from homeassistant.components.fan import ( SPEED_MEDIUM, SPEED_OFF, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.setup import async_setup_component +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) FANS = { "1": { @@ -44,28 +45,19 @@ FANS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, FAN_DOMAIN, {"fan": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_fans(hass): +async def test_no_fans(hass, aioclient_mock): """Test that no fan entities are created.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_fans(hass): +async def test_fans(hass, aioclient_mock): """Test that all supported fan entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(FANS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 # Light and fan @@ -91,104 +83,77 @@ async def test_fans(hass): # Test service calls - ceiling_fan_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service turn on fan - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.ceiling_fan"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 4}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"speed": 4} # Service turn off fan - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.ceiling_fan"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 0}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "fan.ceiling_fan"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"speed": 0} # Service set fan speed to low - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_LOW}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 1}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_LOW}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"speed": 1} # Service set fan speed to medium - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_MEDIUM}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 2}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_MEDIUM}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"speed": 2} # Service set fan speed to high - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_HIGH}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 4}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_HIGH}, + blocking=True, + ) + assert aioclient_mock.mock_calls[5][2] == {"speed": 4} # Service set fan speed to off - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_OFF}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 0}) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_OFF}, + blocking=True, + ) + assert aioclient_mock.mock_calls[6][2] == {"speed": 0} # Service set fan speed to unsupported value - with patch.object( - ceiling_fan_device, "_request", return_value=True - ) as set_callback, pytest.raises(ValueError): + with pytest.raises(ValueError): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_SPEED, {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: "bad value"}, blocking=True, ) - await hass.async_block_till_done() # Events with an unsupported speed gets converted to default speed "medium" @@ -207,4 +172,11 @@ async def test_fans(hass): await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 1790b6ed6e1..5c1642ba8f7 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -29,21 +29,25 @@ from homeassistant.components.ssdp import ( ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import CONN_CLASS_LOCAL_PUSH, SOURCE_SSDP -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON from homeassistant.helpers.dispatcher import async_dispatcher_connect from tests.common import MockConfigEntry API_KEY = "1234567890ABCDEF" BRIDGEID = "01234E56789A" +HOST = "1.2.3.4" +PORT = 80 -ENTRY_CONFIG = {CONF_API_KEY: API_KEY, CONF_HOST: "1.2.3.4", CONF_PORT: 80} +DEFAULT_URL = f"http://{HOST}:{PORT}/api/{API_KEY}" + +ENTRY_CONFIG = {CONF_API_KEY: API_KEY, CONF_HOST: HOST, CONF_PORT: PORT} ENTRY_OPTIONS = {} DECONZ_CONFIG = { "bridgeid": BRIDGEID, - "ipaddress": "1.2.3.4", + "ipaddress": HOST, "mac": "00:11:22:33:44:55", "modelid": "deCONZ", "name": "deCONZ mock gateway", @@ -60,12 +64,41 @@ DECONZ_WEB_REQUEST = { } +def mock_deconz_request(aioclient_mock, config, data): + """Mock a deCONZ get request.""" + host = config[CONF_HOST] + port = config[CONF_PORT] + api_key = config[CONF_API_KEY] + + aioclient_mock.get( + f"http://{host}:{port}/api/{api_key}", + json=deepcopy(data), + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + +def mock_deconz_put_request(aioclient_mock, config, path): + """Mock a deCONZ put request.""" + host = config[CONF_HOST] + port = config[CONF_PORT] + api_key = config[CONF_API_KEY] + + aioclient_mock.put( + f"http://{host}:{port}/api/{api_key}{path}", + json={}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + async def setup_deconz_integration( hass, + aioclient_mock=None, + *, config=ENTRY_CONFIG, options=ENTRY_OPTIONS, get_state_response=DECONZ_WEB_REQUEST, entry_id="1", + unique_id=BRIDGEID, source="user", ): """Create the deCONZ gateway.""" @@ -76,25 +109,27 @@ async def setup_deconz_integration( connection_class=CONN_CLASS_LOCAL_PUSH, options=deepcopy(options), entry_id=entry_id, + unique_id=unique_id, ) config_entry.add_to_hass(hass) - with patch( - "pydeconz.DeconzSession.request", return_value=deepcopy(get_state_response) - ), patch("pydeconz.DeconzSession.start", return_value=True): + if aioclient_mock: + mock_deconz_request(aioclient_mock, config, get_state_response) + + with patch("pydeconz.DeconzSession.start", return_value=True): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry -async def test_gateway_setup(hass): +async def test_gateway_setup(hass, aioclient_mock): """Successful setup.""" with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", return_value=True, ) as forward_entry_setup: - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) assert gateway.bridgeid == BRIDGEID assert gateway.master is True @@ -138,9 +173,9 @@ async def test_gateway_setup_fails(hass): assert not hass.data[DECONZ_DOMAIN] -async def test_connection_status_signalling(hass): +async def test_connection_status_signalling(hass, aioclient_mock): """Make sure that connection status triggers a dispatcher send.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) event_call = Mock() @@ -155,9 +190,9 @@ async def test_connection_status_signalling(hass): unsub() -async def test_update_address(hass): +async def test_update_address(hass, aioclient_mock): """Make sure that connection status triggers a dispatcher send.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) assert gateway.api.host == "1.2.3.4" @@ -193,9 +228,9 @@ async def test_gateway_trigger_reauth_flow(hass): assert hass.data[DECONZ_DOMAIN] == {} -async def test_reset_after_successful_setup(hass): +async def test_reset_after_successful_setup(hass, aioclient_mock): """Make sure that connection status triggers a dispatcher send.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) result = await gateway.async_reset() diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index d408d764d0e..ed7655bf620 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -8,12 +8,20 @@ from homeassistant.components.deconz import ( DeconzGateway, async_setup_entry, async_unload_entry, + async_update_group_unique_id, ) -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.deconz.gateway import get_gateway_from_config_entry +from homeassistant.components.deconz.const import ( + CONF_GROUP_ID_BASE, + DOMAIN as DECONZ_DOMAIN, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.helpers import entity_registry from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from tests.common import MockConfigEntry + ENTRY1_HOST = "1.2.3.4" ENTRY1_PORT = 80 ENTRY1_API_KEY = "1234567890ABCDEF" @@ -49,56 +57,132 @@ async def test_setup_entry_no_available_bridge(hass): assert not hass.data[DECONZ_DOMAIN] -async def test_setup_entry_successful(hass): +async def test_setup_entry_successful(hass, aioclient_mock): """Test setup entry is successful.""" - config_entry = await setup_deconz_integration(hass) - gateway = get_gateway_from_config_entry(hass, config_entry) + config_entry = await setup_deconz_integration(hass, aioclient_mock) assert hass.data[DECONZ_DOMAIN] - assert gateway.bridgeid in hass.data[DECONZ_DOMAIN] - assert hass.data[DECONZ_DOMAIN][gateway.bridgeid].master + assert config_entry.unique_id in hass.data[DECONZ_DOMAIN] + assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master -async def test_setup_entry_multiple_gateways(hass): +async def test_setup_entry_multiple_gateways(hass, aioclient_mock): """Test setup entry is successful with multiple gateways.""" - config_entry = await setup_deconz_integration(hass) - gateway = get_gateway_from_config_entry(hass, config_entry) + config_entry = await setup_deconz_integration(hass, aioclient_mock) + aioclient_mock.clear_requests() data = deepcopy(DECONZ_WEB_REQUEST) data["config"]["bridgeid"] = "01234E56789B" config_entry2 = await setup_deconz_integration( - hass, get_state_response=data, entry_id="2" + hass, + aioclient_mock, + get_state_response=data, + entry_id="2", + unique_id="01234E56789B", ) - gateway2 = get_gateway_from_config_entry(hass, config_entry2) assert len(hass.data[DECONZ_DOMAIN]) == 2 - assert hass.data[DECONZ_DOMAIN][gateway.bridgeid].master - assert not hass.data[DECONZ_DOMAIN][gateway2.bridgeid].master + assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master + assert not hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master -async def test_unload_entry(hass): +async def test_unload_entry(hass, aioclient_mock): """Test being able to unload an entry.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) assert hass.data[DECONZ_DOMAIN] assert await async_unload_entry(hass, config_entry) assert not hass.data[DECONZ_DOMAIN] -async def test_unload_entry_multiple_gateways(hass): +async def test_unload_entry_multiple_gateways(hass, aioclient_mock): """Test being able to unload an entry and master gateway gets moved.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) + aioclient_mock.clear_requests() data = deepcopy(DECONZ_WEB_REQUEST) data["config"]["bridgeid"] = "01234E56789B" config_entry2 = await setup_deconz_integration( - hass, get_state_response=data, entry_id="2" + hass, + aioclient_mock, + get_state_response=data, + entry_id="2", + unique_id="01234E56789B", ) - gateway2 = get_gateway_from_config_entry(hass, config_entry2) assert len(hass.data[DECONZ_DOMAIN]) == 2 assert await async_unload_entry(hass, config_entry) assert len(hass.data[DECONZ_DOMAIN]) == 1 - assert hass.data[DECONZ_DOMAIN][gateway2.bridgeid].master + assert hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master + + +async def test_update_group_unique_id(hass): + """Test successful migration of entry data.""" + old_unique_id = "123" + new_unique_id = "1234" + entry = MockConfigEntry( + domain=DECONZ_DOMAIN, + unique_id=new_unique_id, + data={ + CONF_API_KEY: "1", + CONF_HOST: "2", + CONF_GROUP_ID_BASE: old_unique_id, + CONF_PORT: "3", + }, + ) + + registry = await entity_registry.async_get_registry(hass) + # Create entity entry to migrate to new unique ID + registry.async_get_or_create( + LIGHT_DOMAIN, + DECONZ_DOMAIN, + f"{old_unique_id}-OLD", + suggested_object_id="old", + config_entry=entry, + ) + # Create entity entry with new unique ID + registry.async_get_or_create( + LIGHT_DOMAIN, + DECONZ_DOMAIN, + f"{new_unique_id}-NEW", + suggested_object_id="new", + config_entry=entry, + ) + + await async_update_group_unique_id(hass, entry) + + assert entry.data == {CONF_API_KEY: "1", CONF_HOST: "2", CONF_PORT: "3"} + + old_entity = registry.async_get(f"{LIGHT_DOMAIN}.old") + assert old_entity.unique_id == f"{new_unique_id}-OLD" + + new_entity = registry.async_get(f"{LIGHT_DOMAIN}.new") + assert new_entity.unique_id == f"{new_unique_id}-NEW" + + +async def test_update_group_unique_id_no_legacy_group_id(hass): + """Test migration doesn't trigger without old legacy group id in entry data.""" + old_unique_id = "123" + new_unique_id = "1234" + entry = MockConfigEntry( + domain=DECONZ_DOMAIN, + unique_id=new_unique_id, + data={}, + ) + + registry = await entity_registry.async_get_registry(hass) + # Create entity entry to migrate to new unique ID + registry.async_get_or_create( + LIGHT_DOMAIN, + DECONZ_DOMAIN, + f"{old_unique_id}-OLD", + suggested_object_id="old", + config_entry=entry, + ) + + await async_update_group_unique_id(hass, entry) + + old_entity = registry.async_get(f"{LIGHT_DOMAIN}.old") + assert old_entity.unique_id == f"{old_unique_id}-OLD" diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 20fb50247ee..c7f7fab1868 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1,14 +1,10 @@ """deCONZ light platform tests.""" from copy import deepcopy -from unittest.mock import patch import pytest -from homeassistant.components.deconz.const import ( - CONF_ALLOW_DECONZ_GROUPS, - DOMAIN as DECONZ_DOMAIN, -) +from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -31,10 +27,14 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) GROUPS = { "1": { @@ -106,29 +106,20 @@ LIGHTS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, LIGHT_DOMAIN, {"light": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_lights_or_groups(hass): +async def test_no_lights_or_groups(hass, aioclient_mock): """Test that no lights or groups entities are created.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_lights_and_groups(hass): +async def test_lights_and_groups(hass, aioclient_mock): """Test that lights or groups entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["groups"] = deepcopy(GROUPS) data["lights"] = deepcopy(LIGHTS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 6 @@ -182,73 +173,63 @@ async def test_lights_and_groups(hass): # Verify service calls - rgb_light_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service turn on light with short color loop - with patch.object(rgb_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.rgb_light", - ATTR_COLOR_TEMP: 2500, - ATTR_BRIGHTNESS: 200, - ATTR_TRANSITION: 5, - ATTR_FLASH: FLASH_SHORT, - ATTR_EFFECT: EFFECT_COLORLOOP, - }, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", - "/lights/1/state", - json={ - "ct": 2500, - "bri": 200, - "transitiontime": 50, - "alert": "select", - "effect": "colorloop", - }, - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.rgb_light", + ATTR_COLOR_TEMP: 2500, + ATTR_BRIGHTNESS: 200, + ATTR_TRANSITION: 5, + ATTR_FLASH: FLASH_SHORT, + ATTR_EFFECT: EFFECT_COLORLOOP, + }, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == { + "ct": 2500, + "bri": 200, + "transitiontime": 50, + "alert": "select", + "effect": "colorloop", + } # Service turn on light disabling color loop with long flashing - with patch.object(rgb_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.rgb_light", - ATTR_HS_COLOR: (20, 30), - ATTR_FLASH: FLASH_LONG, - ATTR_EFFECT: "None", - }, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", - "/lights/1/state", - json={"xy": (0.411, 0.351), "alert": "lselect", "effect": "none"}, - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.rgb_light", + ATTR_HS_COLOR: (20, 30), + ATTR_FLASH: FLASH_LONG, + ATTR_EFFECT: "None", + }, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == { + "xy": (0.411, 0.351), + "alert": "lselect", + "effect": "none", + } - # Service turn on light with short flashing + # Service turn on light with short flashing not supported - with patch.object(rgb_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: "light.rgb_light", - ATTR_TRANSITION: 5, - ATTR_FLASH: FLASH_SHORT, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert not set_callback.called + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.rgb_light", + ATTR_TRANSITION: 5, + ATTR_FLASH: FLASH_SHORT, + }, + blocking=True, + ) + assert len(aioclient_mock.mock_calls) == 3 # Not called state_changed_event = { "t": "event", @@ -262,50 +243,52 @@ async def test_lights_and_groups(hass): # Service turn off light with short flashing - with patch.object(rgb_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: "light.rgb_light", - ATTR_TRANSITION: 5, - ATTR_FLASH: FLASH_SHORT, - }, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", - "/lights/1/state", - json={"bri": 0, "transitiontime": 50, "alert": "select"}, - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.rgb_light", + ATTR_TRANSITION: 5, + ATTR_FLASH: FLASH_SHORT, + }, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == { + "bri": 0, + "transitiontime": 50, + "alert": "select", + } # Service turn off light with long flashing - with patch.object(rgb_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.rgb_light", ATTR_FLASH: FLASH_LONG}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/lights/1/state", json={"alert": "lselect"} - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.rgb_light", ATTR_FLASH: FLASH_LONG}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"alert": "lselect"} await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 6 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_disable_light_groups(hass): +async def test_disable_light_groups(hass, aioclient_mock): """Test disallowing light groups work.""" data = deepcopy(DECONZ_WEB_REQUEST) data["groups"] = deepcopy(GROUPS) data["lights"] = deepcopy(LIGHTS) config_entry = await setup_deconz_integration( hass, + aioclient_mock, options={CONF_ALLOW_DECONZ_GROUPS: False}, get_state_response=data, ) @@ -333,7 +316,7 @@ async def test_disable_light_groups(hass): assert hass.states.get("light.light_group") is None -async def test_configuration_tool(hass): +async def test_configuration_tool(hass, aioclient_mock): """Test that lights or groups entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = { @@ -351,12 +334,12 @@ async def test_configuration_tool(hass): "uniqueid": "00:21:2e:ff:ff:05:a7:a3-01", } } - await setup_deconz_integration(hass, get_state_response=data) + await setup_deconz_integration(hass, aioclient_mock, get_state_response=data) assert len(hass.states.async_all()) == 0 -async def test_lidl_christmas_light(hass): +async def test_lidl_christmas_light(hass, aioclient_mock): """Test that lights or groups entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = { @@ -382,33 +365,27 @@ async def test_lidl_christmas_light(hass): "uniqueid": "58:8e:81:ff:fe:db:7b:be-01", } } - config_entry = await setup_deconz_integration(hass, get_state_response=data) - gateway = get_gateway_from_config_entry(hass, config_entry) - xmas_light_device = gateway.api.lights["0"] + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) - assert len(hass.states.async_all()) == 1 + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/0/state") - with patch.object(xmas_light_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.xmas_light", - ATTR_HS_COLOR: (20, 30), - }, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", - "/lights/0/state", - json={"on": True, "hue": 3640, "sat": 76}, - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.xmas_light", + ATTR_HS_COLOR: (20, 30), + }, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True, "hue": 3640, "sat": 76} assert hass.states.get("light.xmas_light") -async def test_non_color_light_reports_color(hass): +async def test_non_color_light_reports_color(hass, aioclient_mock): """Verify hs_color does not crash when a group gets updated with a bad color value. After calling a scene color temp light of certain manufacturers @@ -492,7 +469,9 @@ async def test_non_color_light_reports_color(hass): "uniqueid": "ec:1b:bd:ff:fe:ee:ed:dd-01", }, } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 3 @@ -523,7 +502,7 @@ async def test_non_color_light_reports_color(hass): assert hass.states.get("light.all").attributes[ATTR_HS_COLOR] -async def test_verify_group_supported_features(hass): +async def test_verify_group_supported_features(hass, aioclient_mock): """Test that group supported features reflect what included lights support.""" data = deepcopy(DECONZ_WEB_REQUEST) data["groups"] = deepcopy( @@ -573,7 +552,7 @@ async def test_verify_group_supported_features(hass): }, } ) - await setup_deconz_integration(hass, get_state_response=data) + await setup_deconz_integration(hass, aioclient_mock, get_state_response=data) assert len(hass.states.async_all()) == 4 diff --git a/tests/components/deconz/test_lock.py b/tests/components/deconz/test_lock.py index 7e9b8233778..a6b4caaec19 100644 --- a/tests/components/deconz/test_lock.py +++ b/tests/components/deconz/test_lock.py @@ -1,19 +1,25 @@ """deCONZ lock platform tests.""" from copy import deepcopy -from unittest.mock import patch -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED -from homeassistant.setup import async_setup_component +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) LOCKS = { "1": { @@ -32,28 +38,19 @@ LOCKS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, LOCK_DOMAIN, {"lock": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_locks(hass): +async def test_no_locks(hass, aioclient_mock): """Test that no lock entities are created.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_locks(hass): +async def test_locks(hass, aioclient_mock): """Test that all supported lock entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(LOCKS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 1 @@ -76,32 +73,35 @@ async def test_locks(hass): # Verify service calls - door_lock_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service lock door - with patch.object(door_lock_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LOCK_DOMAIN, - SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.door_lock"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": True}) + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.door_lock"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True} # Service unlock door - with patch.object(door_lock_device, "_request", return_value=True) as set_callback: - await hass.services.async_call( - LOCK_DOMAIN, - SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.door_lock"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": False}) + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.door_lock"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"on": False} await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 1 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_logbook.py b/tests/components/deconz/test_logbook.py index 500ca03b7ed..5886a29a8bf 100644 --- a/tests/components/deconz/test_logbook.py +++ b/tests/components/deconz/test_logbook.py @@ -14,7 +14,7 @@ from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration from tests.components.logbook.test_init import MockLazyEventPartialState -async def test_humanifying_deconz_event(hass): +async def test_humanifying_deconz_event(hass, aioclient_mock): """Test humanifying deCONZ event.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -53,7 +53,9 @@ async def test_humanifying_deconz_event(hass): "uniqueid": "00:00:00:00:00:00:00:04-00", }, } - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) hass.config.components.add("recorder") diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index ca8df2c0425..229111bf9ae 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -1,15 +1,15 @@ """deCONZ scene platform tests.""" from copy import deepcopy -from unittest.mock import patch -from homeassistant.components.deconz import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.setup import async_setup_component -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) GROUPS = { "1": { @@ -24,48 +24,38 @@ GROUPS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, SCENE_DOMAIN, {"scene": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_scenes(hass): +async def test_no_scenes(hass, aioclient_mock): """Test that scenes can be loaded without scenes being available.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_scenes(hass): +async def test_scenes(hass, aioclient_mock): """Test that scenes works.""" data = deepcopy(DECONZ_WEB_REQUEST) data["groups"] = deepcopy(GROUPS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) - gateway = get_gateway_from_config_entry(hass, config_entry) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) assert len(hass.states.async_all()) == 1 assert hass.states.get("scene.light_group_scene") # Verify service calls - group_scene = gateway.api.groups["1"].scenes["1"] + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/groups/1/scenes/1/recall" + ) # Service turn on scene - with patch.object(group_scene, "_request", return_value=True) as set_callback: - await hass.services.async_call( - SCENE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.light_group_scene"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/groups/1/scenes/1/recall", json={}) + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.light_group_scene"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {} await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index def2a1412e5..8a00385ccb9 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -2,18 +2,14 @@ from copy import deepcopy -from homeassistant.components.deconz.const import ( - CONF_ALLOW_CLIP_SENSOR, - DOMAIN as DECONZ_DOMAIN, -) +from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR from homeassistant.components.deconz.gateway import get_gateway_from_config_entry -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, + STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration @@ -85,28 +81,19 @@ SENSORS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, SENSOR_DOMAIN, {"sensor": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_sensors(hass): +async def test_no_sensors(hass, aioclient_mock): """Test that no sensors in deconz results in no sensor entities.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_sensors(hass): +async def test_sensors(hass, aioclient_mock): """Test successful creation of sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 5 @@ -165,15 +152,23 @@ async def test_sensors(hass): await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 5 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_allow_clip_sensors(hass): +async def test_allow_clip_sensors(hass, aioclient_mock): """Test that CLIP sensors can be allowed.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = deepcopy(SENSORS) config_entry = await setup_deconz_integration( hass, + aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True}, get_state_response=data, ) @@ -202,9 +197,9 @@ async def test_allow_clip_sensors(hass): assert hass.states.get("sensor.clip_light_level_sensor") -async def test_add_new_sensor(hass): +async def test_add_new_sensor(hass, aioclient_mock): """Test that adding a new sensor works.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 0 @@ -222,11 +217,13 @@ async def test_add_new_sensor(hass): assert hass.states.get("sensor.light_level_sensor").state == "999.8" -async def test_add_battery_later(hass): +async def test_add_battery_later(hass, aioclient_mock): """Test that a sensor without an initial battery state creates a battery sensor once state exist.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = {"1": deepcopy(SENSORS["3"])} - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) remote = gateway.api.sensors["1"] @@ -244,7 +241,7 @@ async def test_add_battery_later(hass): assert hass.states.get("sensor.switch_1_battery_level") -async def test_air_quality_sensor(hass): +async def test_air_quality_sensor(hass, aioclient_mock): """Test successful creation of air quality sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -266,7 +263,7 @@ async def test_air_quality_sensor(hass): "uniqueid": "00:12:4b:00:14:4d:00:07-02-fdef", } } - await setup_deconz_integration(hass, get_state_response=data) + await setup_deconz_integration(hass, aioclient_mock, get_state_response=data) assert len(hass.states.async_all()) == 1 @@ -274,7 +271,7 @@ async def test_air_quality_sensor(hass): assert air_quality.state == "poor" -async def test_time_sensor(hass): +async def test_time_sensor(hass, aioclient_mock): """Test successful creation of time sensor entities.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { @@ -297,7 +294,7 @@ async def test_time_sensor(hass): "uniqueid": "cc:cc:cc:ff:fe:38:4d:b3-01-000a", } } - await setup_deconz_integration(hass, get_state_response=data) + await setup_deconz_integration(hass, aioclient_mock, get_state_response=data) assert len(hass.states.async_all()) == 2 @@ -308,13 +305,13 @@ async def test_time_sensor(hass): assert time_battery.state == "40" -async def test_unsupported_sensor(hass): +async def test_unsupported_sensor(hass, aioclient_mock): """Test that unsupported sensors doesn't break anything.""" data = deepcopy(DECONZ_WEB_REQUEST) data["sensors"] = { "0": {"type": "not supported", "name": "name", "state": {}, "config": {}} } - await setup_deconz_integration(hass, get_state_response=data) + await setup_deconz_integration(hass, aioclient_mock, get_state_response=data) assert len(hass.states.async_all()) == 1 diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index faa1d3485bb..41eefa95785 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -25,7 +25,13 @@ from homeassistant.components.deconz.services import ( from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.helpers.entity_registry import async_entries_for_config_entry -from .test_gateway import BRIDGEID, DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + BRIDGEID, + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + mock_deconz_request, + setup_deconz_integration, +) GROUP = { "1": { @@ -114,72 +120,66 @@ async def test_service_unload_not_registered(hass): async_remove.assert_not_called() -async def test_configure_service_with_field(hass): +async def test_configure_service_with_field(hass, aioclient_mock): """Test that service invokes pydeconz with the correct path and data.""" - await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) data = { - SERVICE_FIELD: "/light/2", + SERVICE_FIELD: "/lights/2", CONF_BRIDGE_ID: BRIDGEID, SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, } - with patch("pydeconz.DeconzSession.request", return_value=Mock(True)) as put_state: - await hass.services.async_call( - DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data - ) - await hass.async_block_till_done() - put_state.assert_called_with( - "put", "/light/2", json={"on": True, "attr1": 10, "attr2": 20} - ) + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/2") + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data, blocking=True + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True, "attr1": 10, "attr2": 20} -async def test_configure_service_with_entity(hass): +async def test_configure_service_with_entity(hass, aioclient_mock): """Test that service invokes pydeconz with the correct path and data.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) - gateway.deconz_ids["light.test"] = "/light/1" + gateway.deconz_ids["light.test"] = "/lights/1" data = { SERVICE_ENTITY: "light.test", SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, } - with patch("pydeconz.DeconzSession.request", return_value=Mock(True)) as put_state: - await hass.services.async_call( - DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data - ) - await hass.async_block_till_done() - put_state.assert_called_with( - "put", "/light/1", json={"on": True, "attr1": 10, "attr2": 20} - ) + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1") + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data, blocking=True + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True, "attr1": 10, "attr2": 20} -async def test_configure_service_with_entity_and_field(hass): +async def test_configure_service_with_entity_and_field(hass, aioclient_mock): """Test that service invokes pydeconz with the correct path and data.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) - gateway.deconz_ids["light.test"] = "/light/1" + gateway.deconz_ids["light.test"] = "/lights/1" data = { SERVICE_ENTITY: "light.test", SERVICE_FIELD: "/state", SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, } - with patch("pydeconz.DeconzSession.request", return_value=Mock(True)) as put_state: - await hass.services.async_call( - DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data - ) - await hass.async_block_till_done() - put_state.assert_called_with( - "put", "/light/1/state", json={"on": True, "attr1": 10, "attr2": 20} - ) + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data, blocking=True + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True, "attr1": 10, "attr2": 20} -async def test_configure_service_with_faulty_field(hass): +async def test_configure_service_with_faulty_field(hass, aioclient_mock): """Test that service invokes pydeconz with the correct path and data.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) data = {SERVICE_FIELD: "light/2", SERVICE_DATA: {}} @@ -190,9 +190,9 @@ async def test_configure_service_with_faulty_field(hass): await hass.async_block_till_done() -async def test_configure_service_with_faulty_entity(hass): +async def test_configure_service_with_faulty_entity(hass, aioclient_mock): """Test that service invokes pydeconz with the correct path and data.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) data = { SERVICE_ENTITY: "light.nonexisting", @@ -207,21 +207,24 @@ async def test_configure_service_with_faulty_entity(hass): put_state.assert_not_called() -async def test_service_refresh_devices(hass): +async def test_service_refresh_devices(hass, aioclient_mock): """Test that service can refresh devices.""" - config_entry = await setup_deconz_integration(hass) + config_entry = await setup_deconz_integration(hass, aioclient_mock) gateway = get_gateway_from_config_entry(hass, config_entry) + aioclient_mock.clear_requests() data = {CONF_BRIDGE_ID: BRIDGEID} - with patch( - "pydeconz.DeconzSession.request", - return_value={"groups": GROUP, "lights": LIGHT, "sensors": SENSOR}, - ): - await hass.services.async_call( - DECONZ_DOMAIN, SERVICE_DEVICE_REFRESH, service_data=data - ) - await hass.async_block_till_done() + mock_deconz_request( + aioclient_mock, + config_entry.data, + {"groups": GROUP, "lights": LIGHT, "sensors": SENSOR}, + ) + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_DEVICE_REFRESH, service_data=data + ) + await hass.async_block_till_done() assert gateway.deconz_ids == { "light.group_1_name": "/groups/1", @@ -231,12 +234,14 @@ async def test_service_refresh_devices(hass): } -async def test_remove_orphaned_entries_service(hass): +async def test_remove_orphaned_entries_service(hass, aioclient_mock): """Test service works and also don't remove more than expected.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(LIGHT) data["sensors"] = deepcopy(SWITCH) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) data = {CONF_BRIDGE_ID: BRIDGEID} diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index e42e89d903e..6aafac1bd42 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -1,19 +1,20 @@ """deCONZ switch platform tests.""" from copy import deepcopy -from unittest.mock import patch -from homeassistant.components.deconz import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.setup import async_setup_component +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) POWER_PLUGS = { "1": { @@ -64,28 +65,19 @@ SIRENS = { } -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a gateway.""" - assert ( - await async_setup_component( - hass, SWITCH_DOMAIN, {"switch": {"platform": DECONZ_DOMAIN}} - ) - is True - ) - assert DECONZ_DOMAIN not in hass.data - - -async def test_no_switches(hass): +async def test_no_switches(hass, aioclient_mock): """Test that no switch entities are created.""" - await setup_deconz_integration(hass) + await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 -async def test_power_plugs(hass): +async def test_power_plugs(hass, aioclient_mock): """Test that all supported switch entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(POWER_PLUGS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 4 @@ -107,46 +99,47 @@ async def test_power_plugs(hass): # Verify service calls - on_off_switch_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service turn on power plug - with patch.object( - on_off_switch_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.on_off_switch"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": True}) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.on_off_switch"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"on": True} # Service turn off power plug - with patch.object( - on_off_switch_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.on_off_switch"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with("put", "/lights/1/state", json={"on": False}) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.on_off_switch"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"on": False} await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 4 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 -async def test_sirens(hass): +async def test_sirens(hass, aioclient_mock): """Test that siren entities are created.""" data = deepcopy(DECONZ_WEB_REQUEST) data["lights"] = deepcopy(SIRENS) - config_entry = await setup_deconz_integration(hass, get_state_response=data) + config_entry = await setup_deconz_integration( + hass, aioclient_mock, get_state_response=data + ) gateway = get_gateway_from_config_entry(hass, config_entry) assert len(hass.states.async_all()) == 2 @@ -166,40 +159,35 @@ async def test_sirens(hass): # Verify service calls - warning_device_device = gateway.api.lights["1"] + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") # Service turn on siren - with patch.object( - warning_device_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/lights/1/state", json={"alert": "lselect"} - ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} # Service turn off siren - with patch.object( - warning_device_device, "_request", return_value=True - ) as set_callback: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - await hass.async_block_till_done() - set_callback.assert_called_with( - "put", "/lights/1/state", json={"alert": "none"} - ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} await hass.config_entries.async_unload(config_entry.entry_id) + states = hass.states.async_all() + assert len(hass.states.async_all()) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 2dca57d3e6b..a788e69b0d3 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -2,6 +2,12 @@ import pytest from homeassistant.components import fan +from homeassistant.components.demo.fan import ( + PRESET_MODE_AUTO, + PRESET_MODE_ON, + PRESET_MODE_SLEEP, + PRESET_MODE_SMART, +) from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, @@ -12,7 +18,16 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -FAN_ENTITY_ID = "fan.living_room_fan" +FULL_FAN_ENTITY_IDS = ["fan.living_room_fan", "fan.percentage_full_fan"] +FANS_WITH_PRESET_MODE_ONLY = ["fan.preset_only_limited_fan"] +LIMITED_AND_FULL_FAN_ENTITY_IDS = FULL_FAN_ENTITY_IDS + [ + "fan.ceiling_fan", + "fan.percentage_limited_fan", +] +FANS_WITH_PRESET_MODES = FULL_FAN_ENTITY_IDS + [ + "fan.percentage_limited_fan", +] +PERCENTAGE_MODEL_FANS = ["fan.percentage_full_fan", "fan.percentage_limited_fan"] @pytest.fixture(autouse=True) @@ -22,124 +37,521 @@ async def setup_comp(hass): await hass.async_block_till_done() -async def test_turn_on(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_on(hass, fan_entity_id): """Test turning on the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_turn_on_with_speed_and_percentage(hass, fan_entity_id): + """Test turning on the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_HIGH}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_SPEED: fan.SPEED_HIGH}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_MEDIUM}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_LOW}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 66}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 0}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 -async def test_turn_off(hass): +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODE_ONLY) +async def test_turn_on_with_preset_mode_only(hass, fan_entity_id): + """Test turning on the device with a preset_mode and no speed setting.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PRESET_MODES] == [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ] + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART + + await hass.services.async_call( + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) +async def test_turn_on_with_preset_mode_and_speed(hass, fan_entity_id): + """Test turning on the device with a preset_mode and speed.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_SPEED_LIST] == [ + fan.SPEED_OFF, + fan.SPEED_LOW, + fan.SPEED_MEDIUM, + fan.SPEED_HIGH, + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ] + assert state.attributes[fan.ATTR_PRESET_MODES] == [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODE_SLEEP, + PRESET_MODE_ON, + ] + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_SMART + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART + + await hass.services.async_call( + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_off(hass, fan_entity_id): """Test turning off the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF -async def test_turn_off_without_entity_id(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_off_without_entity_id(hass, fan_entity_id): """Test turning off all fans.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF -async def test_set_direction(hass): +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_direction(hass, fan_entity_id): """Test setting the direction of the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_DIRECTION] == fan.DIRECTION_REVERSE -async def test_set_speed(hass): +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_speed(hass, fan_entity_id): """Test setting the speed of the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_SPEED: fan.SPEED_LOW}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_LOW}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW -async def test_oscillate(hass): +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) +async def test_set_preset_mode(hass, fan_entity_id): + """Test setting the preset mode of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + + +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_set_preset_mode_invalid(hass, fan_entity_id): + """Test setting a invalid preset mode for the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_percentage(hass, fan_entity_id): + """Test setting the percentage speed of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_increase_decrease_speed(hass, fan_entity_id): + """Test increasing and decreasing the percentage speed of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PERCENTAGE_STEP] == 100 / 3 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + + +@pytest.mark.parametrize("fan_entity_id", PERCENTAGE_MODEL_FANS) +async def test_increase_decrease_speed_with_percentage_step(hass, fan_entity_id): + """Test increasing speed with a percentage step.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 25 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 75 + + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_oscillate(hass, fan_entity_id): """Test oscillating the fan.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF assert not state.attributes.get(fan.ATTR_OSCILLATING) await hass.services.async_call( fan.DOMAIN, fan.SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_OSCILLATING: True}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: True}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_OSCILLATING] is True await hass.services.async_call( fan.DOMAIN, fan.SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_OSCILLATING: False}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: False}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_OSCILLATING] is False -async def test_is_on(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_is_on(hass, fan_entity_id): """Test is on service call.""" - assert not fan.is_on(hass, FAN_ENTITY_ID) + assert not fan.is_on(hass, fan_entity_id) await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - assert fan.is_on(hass, FAN_ENTITY_ID) + assert fan.is_on(hass, fan_entity_id) diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 7c7f83312dd..153f065235c 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -12,7 +12,7 @@ from homeassistant.core import callback from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from tests.common import assert_setup_component, async_capture_events CONFIG = {notify.DOMAIN: {"platform": "demo"}} @@ -20,9 +20,7 @@ CONFIG = {notify.DOMAIN: {"platform": "demo"}} @pytest.fixture def events(hass): """Fixture that catches notify events.""" - events = [] - hass.bus.async_listen(demo.EVENT_NOTIFY, callback(lambda e: events.append(e))) - yield events + return async_capture_events(hass, demo.EVENT_NOTIFY) @pytest.fixture diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py index 5e1e323cad8..5ffc0781c84 100644 --- a/tests/components/devolo_home_control/__init__.py +++ b/tests/components/devolo_home_control/__init__.py @@ -1 +1,19 @@ """Tests for the devolo_home_control integration.""" + +from homeassistant.components.devolo_home_control.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + "username": "test-username", + "password": "test-password", + "mydevolo_url": "https://test_mydevolo_url.test", + } + entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id="123456") + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/devolo_home_control/conftest.py b/tests/components/devolo_home_control/conftest.py new file mode 100644 index 00000000000..487831b0fa4 --- /dev/null +++ b/tests/components/devolo_home_control/conftest.py @@ -0,0 +1,33 @@ +"""Fixtures for tests.""" + +from unittest.mock import patch + +import pytest + + +def pytest_configure(config): + """Define custom markers.""" + config.addinivalue_line( + "markers", + "credentials_invalid: Treat credentials as invalid.", + ) + config.addinivalue_line( + "markers", + "maintenance: Set maintenance mode to on.", + ) + + +@pytest.fixture(autouse=True) +def patch_mydevolo(request): + """Fixture to patch mydevolo into a desired state.""" + with patch( + "homeassistant.components.devolo_home_control.Mydevolo.credentials_valid", + return_value=not bool(request.node.get_closest_marker("credentials_invalid")), + ), patch( + "homeassistant.components.devolo_home_control.Mydevolo.maintenance", + return_value=bool(request.node.get_closest_marker("maintenance")), + ), patch( + "homeassistant.components.devolo_home_control.Mydevolo.get_gateway_ids", + return_value=["1400000000000001", "1400000000000002"], + ): + yield diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index dd856d2e6b5..7d2c9ce40f6 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -1,6 +1,8 @@ """Test the devolo_home_control config flow.""" from unittest.mock import patch +import pytest + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -24,9 +26,6 @@ async def test_form(hass): "homeassistant.components.devolo_home_control.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", - return_value=True, - ), patch( "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", return_value="123456", ): @@ -48,6 +47,7 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.credentials_invalid async def test_form_invalid_credentials(hass): """Test if we get the error message on invalid credentials.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -57,16 +57,12 @@ async def test_form_invalid_credentials(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": "invalid_auth"} async def test_form_already_configured(hass): @@ -74,9 +70,6 @@ async def test_form_already_configured(hass): with patch( "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", return_value="123456", - ), patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", - return_value=True, ): MockConfigEntry(domain=DOMAIN, unique_id="123456", data={}).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -103,9 +96,6 @@ async def test_form_advanced_options(hass): "homeassistant.components.devolo_home_control.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", - return_value=True, - ), patch( "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", return_value="123456", ): diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py new file mode 100644 index 00000000000..f45400716f8 --- /dev/null +++ b/tests/components/devolo_home_control/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the devolo Home Control integration.""" +from unittest.mock import patch + +from devolo_home_control_api.exceptions.gateway import GatewayOfflineError +import pytest + +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.core import HomeAssistant + +from tests.components.devolo_home_control import configure_integration + + +async def test_setup_entry(hass: HomeAssistant): + """Test setup entry.""" + entry = configure_integration(hass) + with patch("homeassistant.components.devolo_home_control.HomeControl"): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_LOADED + + +@pytest.mark.credentials_invalid +async def test_setup_entry_credentials_invalid(hass: HomeAssistant): + """Test setup entry fails if credentials are invalid.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_ERROR + + +@pytest.mark.maintenance +async def test_setup_entry_maintenance(hass: HomeAssistant): + """Test setup entry fails if mydevolo is in maintenance mode.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_setup_connection_error(hass: HomeAssistant): + """Test setup entry fails on connection error.""" + entry = configure_integration(hass) + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=ConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_setup_gateway_offline(hass: HomeAssistant): + """Test setup entry fails on gateway offline.""" + entry = configure_integration(hass) + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=GatewayOfflineError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_entry(hass: HomeAssistant): + """Test unload entry.""" + entry = configure_integration(hass) + with patch("homeassistant.components.devolo_home_control.HomeControl"): + 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 == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index fd66e59ef21..2c1e41e8285 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -38,19 +38,17 @@ async def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Mock discoveries.""" with patch("homeassistant.components.zeroconf.async_get_instance"), patch( "homeassistant.components.zeroconf.async_setup", return_value=True - ): - assert await async_setup_component(hass, "discovery", config) - await hass.async_block_till_done() - await hass.async_start() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - with patch.object(discovery, "_discover", discoveries), patch( + ), patch.object(discovery, "_discover", discoveries), patch( "homeassistant.components.discovery.async_discover", return_value=mock_coro() ) as mock_discover, patch( "homeassistant.components.discovery.async_load_platform", return_value=mock_coro(), ) as mock_platform: + assert await async_setup_component(hass, "discovery", config) + await hass.async_block_till_done() + await hass.async_start() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) # Work around an issue where our loop.call_soon not get caught await hass.async_block_till_done() diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index dde66c6bfb7..31c4f2be8db 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -183,7 +183,7 @@ async def test_derivative(): config = {"platform": "dsmr"} - entity = DerivativeDSMREntity("test", "test_device", "5678", "1.0.0", config) + entity = DerivativeDSMREntity("test", "test_device", "5678", "1.0.0", config, False) await entity.async_update() assert entity.state is None, "initial state not unknown" diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 48ec378689e..072e222c194 100644 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, Mock, call, patch from homeassistant.components import dynalite +from homeassistant.const import ATTR_SERVICE from homeassistant.helpers import entity_registry from tests.common import MockConfigEntry -ATTR_SERVICE = "service" ATTR_METHOD = "method" ATTR_ARGS = "args" diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index d231f82d2f8..eab88fb18ca 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -7,7 +7,7 @@ import pytest from voluptuous import MultipleInvalid import homeassistant.components.dynalite.const as dynalite -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -54,7 +54,7 @@ async def test_async_setup(hass): dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, }, }, - dynalite.CONF_DEFAULT: {dynalite.CONF_FADE: 2.3}, + CONF_DEFAULT: {dynalite.CONF_FADE: 2.3}, dynalite.CONF_ACTIVE: dynalite.ACTIVE_INIT, dynalite.CONF_PRESET: { "5": {CONF_NAME: "pres5", dynalite.CONF_FADE: 4.5} diff --git a/tests/components/dynalite/test_light.py b/tests/components/dynalite/test_light.py index 7df10fb08e8..230e7584d70 100644 --- a/tests/components/dynalite/test_light.py +++ b/tests/components/dynalite/test_light.py @@ -4,7 +4,11 @@ from dynalite_devices_lib.light import DynaliteChannelLightDevice import pytest from homeassistant.components.light import SUPPORT_BRIGHTNESS -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, +) from .common import ( ATTR_METHOD, @@ -40,11 +44,21 @@ async def test_light_setup(hass, mock_device): ) -async def test_remove_entity(hass, mock_device): - """Test when an entity is removed from HA.""" +async def test_unload_config_entry(hass, mock_device): + """Test when a config entry is unloaded from HA.""" await create_entity_from_device(hass, mock_device) assert hass.states.get("light.name") entry_id = await get_entry_id_from_hass(hass) assert await hass.config_entries.async_unload(entry_id) await hass.async_block_till_done() + assert hass.states.get("light.name").state == STATE_UNAVAILABLE + + +async def test_remove_config_entry(hass, mock_device): + """Test when a config entry is removed from HA.""" + await create_entity_from_device(hass, mock_device) + assert hass.states.get("light.name") + entry_id = await get_entry_id_from_hass(hass) + assert await hass.config_entries.async_remove(entry_id) + await hass.async_block_till_done() assert not hass.states.get("light.name") diff --git a/tests/components/dyson/test_fan.py b/tests/components/dyson/test_fan.py index 310d9197133..dacde12c569 100644 --- a/tests/components/dyson/test_fan.py +++ b/tests/components/dyson/test_fan.py @@ -19,16 +19,18 @@ from homeassistant.components.dyson.fan import ( 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, - SPEED_LOW, ) from homeassistant.components.fan import ( ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, ATTR_SPEED_LIST, DOMAIN as PLATFORM_DOMAIN, @@ -37,7 +39,9 @@ from homeassistant.components.fan import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, SPEED_HIGH, + SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, ) @@ -84,8 +88,16 @@ async def test_state_purecoollink( 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_LOW, SPEED_MEDIUM, SPEED_HIGH] + 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 @@ -106,7 +118,9 @@ async def test_state_purecoollink( attributes = state.attributes assert attributes[ATTR_NIGHT_MODE] is False assert attributes[ATTR_OSCILLATING] is False - assert attributes[ATTR_SPEED] == SPEED_MEDIUM + 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 @@ -125,8 +139,16 @@ async def test_state_purecool(hass: HomeAssistant, device: DysonPureCool) -> Non 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_LOW, SPEED_MEDIUM, SPEED_HIGH] + 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 @@ -148,7 +170,9 @@ async def test_state_purecool(hass: HomeAssistant, device: DysonPureCool) -> Non attributes = state.attributes assert attributes[ATTR_NIGHT_MODE] is False assert attributes[ATTR_OSCILLATING] is False - assert attributes[ATTR_SPEED] == SPEED_MEDIUM + 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 @@ -170,6 +194,11 @@ async def test_state_purecool(hass: HomeAssistant, device: DysonPureCool) -> Non {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, @@ -229,6 +258,18 @@ async def test_commands_purecoollink( "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", []), diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index a7ee0403c7c..3f2eb72a8e3 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -5,7 +5,7 @@ import aiohttp import pytest from homeassistant import config_entries -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -428,5 +428,8 @@ async def test_unload_entry(hass, mock_get_station): assert await entry.async_unload(hass) - # And the entity should be gone - assert not hass.states.get("sensor.my_station_water_level_stage") + # And the entity should be unavailable + assert ( + hass.states.get("sensor.my_station_water_level_stage").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index a9b9165d713..975fabcf9ab 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -209,14 +209,14 @@ async def test_set_temperature(ecobee_fixture, thermostat, data): data.reset_mock() thermostat.set_temperature(target_temp_low=20, target_temp_high=30) data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 30, 20, "nextTransition", 0)] + [mock.call(1, 30, 20, "nextTransition", None)] ) # Auto -> Hold data.reset_mock() thermostat.set_temperature(temperature=20) data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 25, 15, "nextTransition", 0)] + [mock.call(1, 25, 15, "nextTransition", None)] ) # Cool -> Hold @@ -224,7 +224,7 @@ async def test_set_temperature(ecobee_fixture, thermostat, data): ecobee_fixture["settings"]["hvacMode"] = "cool" thermostat.set_temperature(temperature=20.5) data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 20.5, 20.5, "nextTransition", 0)] + [mock.call(1, 20.5, 20.5, "nextTransition", None)] ) # Heat -> Hold @@ -232,7 +232,7 @@ async def test_set_temperature(ecobee_fixture, thermostat, data): ecobee_fixture["settings"]["hvacMode"] = "heat" thermostat.set_temperature(temperature=20) data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 20, 20, "nextTransition", 0)] + [mock.call(1, 20, 20, "nextTransition", None)] ) # Heat -> Auto @@ -311,7 +311,7 @@ def test_hold_hours(ecobee_fixture, thermostat): "askMe", ]: ecobee_fixture["settings"]["holdAction"] = action - assert thermostat.hold_hours() == 0 + assert thermostat.hold_hours() is None async def test_set_fan_mode_on(thermostat, data): diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py index 3b1942aee14..ea63bc0c4d0 100644 --- a/tests/components/elgato/__init__.py +++ b/tests/components/elgato/__init__.py @@ -14,27 +14,26 @@ async def init_integration( skip_setup: bool = False, ) -> MockConfigEntry: """Set up the Elgato Key Light integration in Home Assistant.""" - aioclient_mock.get( - "http://1.2.3.4:9123/elgato/accessory-info", + "http://127.0.0.1:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.put( - "http://1.2.3.4:9123/elgato/lights", + "http://127.0.0.1:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( - "http://1.2.3.4:9123/elgato/lights", + "http://127.0.0.1:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) aioclient_mock.get( - "http://5.6.7.8:9123/elgato/accessory-info", + "http://127.0.0.2:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) @@ -43,7 +42,7 @@ async def init_integration( domain=DOMAIN, unique_id="CN11A1A00001", data={ - CONF_HOST: "1.2.3.4", + CONF_HOST: "127.0.0.1", CONF_PORT: 9123, CONF_SERIAL_NUMBER: "CN11A1A00001", }, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index c1dfa697041..0f3ff032722 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -2,10 +2,9 @@ import aiohttp from homeassistant import data_entry_flow -from homeassistant.components.elgato import config_flow -from homeassistant.components.elgato.const import CONF_SERIAL_NUMBER +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, CONTENT_TYPE_JSON +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from . import init_integration @@ -14,62 +13,97 @@ from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.get( + "http://127.0.0.1:9123/elgato/accessory-info", + text=load_fixture("elgato/info.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + # Start a discovered configuration flow, to guarantee a user flow doesn't abort + 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": {}, + }, + ) + result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, ) 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={CONF_HOST: "127.0.0.1", CONF_PORT: 9123} + ) -async def test_show_zeroconf_confirm_form(hass: HomeAssistant) -> None: - """Test that the zeroconf confirmation form is served.""" - flow = config_flow.ElgatoFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_ZEROCONF, CONF_SERIAL_NUMBER: "12345"} - result = await flow.async_step_zeroconf_confirm() + assert result["data"][CONF_HOST] == "127.0.0.1" + assert result["data"][CONF_PORT] == 9123 + assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" + assert result["title"] == "CN11A1A00001" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "12345"} - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].unique_id == "CN11A1A00001" -async def test_show_zerconf_form( +async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test that the zeroconf confirmation form is served.""" + """Test the zeroconf flow from start to finish.""" aioclient_mock.get( - "http://1.2.3.4:9123/elgato/accessory-info", + "http://127.0.0.1:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) - flow = config_flow.ElgatoFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) + 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": {}, + }, + ) - assert flow.context[CONF_HOST] == "1.2.3.4" - assert flow.context[CONF_PORT] == 9123 - assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} assert result["step_id"] == "zeroconf_confirm" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["data"][CONF_HOST] == "127.0.0.1" + assert result["data"][CONF_PORT] == 9123 + assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" + assert result["title"] == "CN11A1A00001" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + async def test_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we show user form on Elgato Key Light connection error.""" - aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) + aioclient_mock.get( + "http://127.0.0.1/elgato/accessory-info", exc=aiohttp.ClientError + ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, ) assert result["errors"] == {"base": "cannot_connect"} @@ -81,51 +115,20 @@ async def test_zeroconf_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) + aioclient_mock.get( + "http://127.0.0.1/elgato/accessory-info", exc=aiohttp.ClientError + ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "1.2.3.4", "port": 9123}, + data={"host": "127.0.0.1", "port": 9123}, ) assert result["reason"] == "cannot_connect" assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT -async def test_zeroconf_confirm_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) - - flow = config_flow.ElgatoFlowHandler() - flow.hass = hass - flow.context = { - "source": SOURCE_ZEROCONF, - CONF_HOST: "1.2.3.4", - CONF_PORT: 9123, - } - result = await flow.async_step_zeroconf_confirm( - user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} - ) - - assert result["reason"] == "cannot_connect" - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - - -async def test_zeroconf_no_data( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort if zeroconf provides no data.""" - flow = config_flow.ElgatoFlowHandler() - flow.hass = hass - result = await flow.async_step_zeroconf() - - assert result["reason"] == "cannot_connect" - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - - async def test_user_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -133,9 +136,9 @@ async def test_user_device_exists_abort( await init_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -148,84 +151,22 @@ async def test_zeroconf_device_exists_abort( await init_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data={"host": "1.2.3.4", "port": 9123}, + DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data={"host": "127.0.0.1", "port": 9123}, ) assert result["reason"] == "already_configured" assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_ZEROCONF, CONF_HOST: "1.2.3.4", "port": 9123}, - data={"host": "5.6.7.8", "port": 9123}, + DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data={"host": "127.0.0.2", "port": 9123}, ) assert result["reason"] == "already_configured" assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].data[CONF_HOST] == "5.6.7.8" - - -async def test_full_user_flow_implementation( - hass: HomeAssistant, aioclient_mock -) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.get( - "http://1.2.3.4:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - ) - - 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={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} - ) - - assert result["data"][CONF_HOST] == "1.2.3.4" - assert result["data"][CONF_PORT] == 9123 - assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" - assert result["title"] == "CN11A1A00001" - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].unique_id == "CN11A1A00001" - - -async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.get( - "http://1.2.3.4:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - flow = config_flow.ElgatoFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) - - assert flow.context[CONF_HOST] == "1.2.3.4" - assert flow.context[CONF_PORT] == 9123 - assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" - assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - - result = await flow.async_step_zeroconf_confirm(user_input={CONF_HOST: "1.2.3.4"}) - assert result["data"][CONF_HOST] == "1.2.3.4" - assert result["data"][CONF_PORT] == 9123 - assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" - assert result["title"] == "CN11A1A00001" - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == "127.0.0.2" diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index 2f0e39e05a8..069e533c423 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -14,7 +14,7 @@ async def test_config_entry_not_ready( ) -> None: """Test the Elgato Key Light configuration entry not ready.""" aioclient_mock.get( - "http://1.2.3.4:9123/elgato/accessory-info", exc=aiohttp.ClientError + "http://127.0.0.1:9123/elgato/accessory-info", exc=aiohttp.ClientError ) entry = await init_integration(hass, aioclient_mock) diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 838608c0aac..aed569c18fe 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -1,7 +1,8 @@ """Tests for the Elgato Key Light light platform.""" from unittest.mock import patch -from homeassistant.components.elgato.light import ElgatoError +from elgato import ElgatoError + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 4d8079b9db9..e3f965616f9 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -28,6 +28,8 @@ from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_HUE, HUE_API_STATE_ON, HUE_API_STATE_SAT, + HUE_API_STATE_TRANSITION, + HUE_API_STATE_XY, HUE_API_USERNAME, HueAllGroupsStateView, HueAllLightsStateView, @@ -39,6 +41,7 @@ from homeassistant.components.emulated_hue.hue_api import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, CONTENT_TYPE_JSON, HTTP_NOT_FOUND, HTTP_OK, @@ -51,7 +54,11 @@ from homeassistant.const import ( from homeassistant.core import callback import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, get_test_instance_port +from tests.common import ( + async_fire_time_changed, + async_mock_service, + get_test_instance_port, +) HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() @@ -70,16 +77,19 @@ ENTITY_IDS_BY_NUMBER = { "8": "media_player.lounge_room", "9": "fan.living_room_fan", "10": "fan.ceiling_fan", - "11": "cover.living_room_window", - "12": "climate.hvac", - "13": "climate.heatpump", - "14": "climate.ecobee", - "15": "light.no_brightness", - "16": "humidifier.humidifier", - "17": "humidifier.dehumidifier", - "18": "humidifier.hygrostat", - "19": "scene.light_on", - "20": "scene.light_off", + "11": "fan.percentage_full_fan", + "12": "fan.percentage_limited_fan", + "13": "fan.preset_only_limited_fan", + "14": "cover.living_room_window", + "15": "climate.hvac", + "16": "climate.heatpump", + "17": "climate.ecobee", + "18": "light.no_brightness", + "19": "humidifier.humidifier", + "20": "humidifier.dehumidifier", + "21": "humidifier.hygrostat", + "22": "scene.light_on", + "23": "scene.light_off", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -660,6 +670,25 @@ async def test_put_light_state(hass, hass_hue, hue_client): assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + brightness=100, + xy=((0.488, 0.48)), + ) + + # go through api to get the state back + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + assert ceiling_json["state"][HUE_API_STATE_BRI] == 100 + assert hass.states.get("light.ceiling_lights").attributes[light.ATTR_XY_COLOR] == ( + (0.488, 0.48) + ) + # Go through the API to turn it off ceiling_result = await perform_put_light_state( hass_hue, hue_client, "light.ceiling_lights", False @@ -711,6 +740,30 @@ async def test_put_light_state(hass, hass_hue, hue_client): == 50 ) + # mock light.turn_on call + hass.states.async_set( + "light.ceiling_lights", STATE_ON, {ATTR_SUPPORTED_FEATURES: 55} + ) + call_turn_on = async_mock_service(hass, "light", "turn_on") + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + brightness=99, + xy=((0.488, 0.48)), + transitiontime=60, + ) + + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == ["light.ceiling_lights"] + assert call_turn_on[0].data[light.ATTR_BRIGHTNESS] == 99 + assert call_turn_on[0].data[light.ATTR_XY_COLOR] == ((0.488, 0.48)) + assert call_turn_on[0].data[light.ATTR_TRANSITION] == 6 + async def test_put_light_state_script(hass, hass_hue, hue_client): """Test the setting of script variables.""" @@ -1170,6 +1223,8 @@ async def perform_put_light_state( saturation=None, color_temp=None, with_state=True, + xy=None, + transitiontime=None, ): """Test the setting of a light state.""" req_headers = {"Content-Type": content_type} @@ -1185,8 +1240,12 @@ async def perform_put_light_state( data[HUE_API_STATE_HUE] = hue if saturation is not None: data[HUE_API_STATE_SAT] = saturation + if xy is not None: + data[HUE_API_STATE_XY] = xy if color_temp is not None: data[HUE_API_STATE_CT] = color_temp + if transitiontime is not None: + data[HUE_API_STATE_TRANSITION] = transitiontime entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.put( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f3afce0d43b..233255c1a89 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.esphome import DATA_KEY +from homeassistant.components.esphome import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -207,7 +207,7 @@ async def test_discovery_initiation(hass, mock_client): async def test_discovery_already_configured_hostname(hass, mock_client): """Test discovery aborts if already configured via hostname.""" entry = MockConfigEntry( - domain="esphome", + domain=DOMAIN, data={CONF_HOST: "test8266.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, ) @@ -232,7 +232,7 @@ async def test_discovery_already_configured_hostname(hass, mock_client): async def test_discovery_already_configured_ip(hass, mock_client): """Test discovery aborts if already configured via static IP.""" entry = MockConfigEntry( - domain="esphome", + domain=DOMAIN, data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, ) @@ -257,14 +257,14 @@ async def test_discovery_already_configured_ip(hass, mock_client): async def test_discovery_already_configured_name(hass, mock_client): """Test discovery aborts if already configured via name.""" entry = MockConfigEntry( - domain="esphome", + domain=DOMAIN, data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, ) entry.add_to_hass(hass) mock_entry_data = MagicMock() mock_entry_data.device_info.name = "test8266" - hass.data[DATA_KEY] = {entry.entry_id: mock_entry_data} + hass.data[DOMAIN] = {entry.entry_id: mock_entry_data} service_info = { "host": "192.168.43.184", @@ -310,7 +310,7 @@ async def test_discovery_duplicate_data(hass, mock_client): async def test_discovery_updates_unique_id(hass, mock_client): """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( - domain="esphome", + domain=DOMAIN, data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, ) diff --git a/tests/components/faa_delays/__init__.py b/tests/components/faa_delays/__init__.py new file mode 100644 index 00000000000..2bb5194605d --- /dev/null +++ b/tests/components/faa_delays/__init__.py @@ -0,0 +1 @@ +"""Tests for the FAA Delays integration.""" diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py new file mode 100644 index 00000000000..c289f154415 --- /dev/null +++ b/tests/components/faa_delays/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the FAA Delays config flow.""" +from unittest.mock import patch + +from aiohttp import ClientConnectionError +import faadelays + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.faa_delays.const import DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def mock_valid_airport(self, *args, **kwargs): + """Return a valid airport.""" + self.name = "Test airport" + + +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"] == "form" + assert result["errors"] == {} + + with patch.object(faadelays.Airport, "update", new=mock_valid_airport), patch( + "homeassistant.components.faa_delays.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.faa_delays.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test airport" + assert result2["data"] == { + "id": "test", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_error(hass): + """Test that we handle a duplicate configuration.""" + conf = {CONF_ID: "test"} + + MockConfigEntry(domain=DOMAIN, unique_id="test", data=conf).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_invalid_airport(hass): + """Test we handle invalid airport.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "faadelays.Airport.update", + side_effect=faadelays.InvalidAirport, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_ID: "invalid_airport"} + + +async def test_form_cannot_connect(hass): + """Test we handle a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("faadelays.Airport.update", side_effect=ClientConnectionError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_exception(hass): + """Test we handle an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("faadelays.Airport.update", side_effect=HomeAssistantError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/fan/common.py b/tests/components/fan/common.py index 70a2c7e43d3..c32686b9311 100644 --- a/tests/components/fan/common.py +++ b/tests/components/fan/common.py @@ -6,10 +6,17 @@ components. Instead call the service directly. from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, ) from homeassistant.const import ( @@ -20,11 +27,22 @@ from homeassistant.const import ( ) -async def async_turn_on(hass, entity_id=ENTITY_MATCH_ALL, speed: str = None) -> None: +async def async_turn_on( + hass, + entity_id=ENTITY_MATCH_ALL, + speed: str = None, + percentage: int = None, + preset_mode: str = None, +) -> None: """Turn all or specified fan on.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_SPEED, speed)] + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + (ATTR_PERCENTAGE, percentage), + (ATTR_PRESET_MODE, preset_mode), + ] if value is not None } @@ -65,6 +83,64 @@ async def async_set_speed(hass, entity_id=ENTITY_MATCH_ALL, speed: str = None) - await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True) +async def async_set_preset_mode( + hass, entity_id=ENTITY_MATCH_ALL, preset_mode: str = None +) -> None: + """Set preset mode for all or specified fan.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True) + + +async def async_set_percentage( + hass, entity_id=ENTITY_MATCH_ALL, percentage: int = None +) -> None: + """Set percentage for all or specified fan.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_PERCENTAGE, data, blocking=True) + + +async def async_increase_speed( + hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int = None +) -> None: + """Increase speed for all or specified fan.""" + data = { + key: value + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_PERCENTAGE_STEP, percentage_step), + ] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_INCREASE_SPEED, data, blocking=True) + + +async def async_decrease_speed( + hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int = None +) -> None: + """Decrease speed for all or specified fan.""" + data = { + key: value + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_PERCENTAGE_STEP, percentage_step), + ] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_DECREASE_SPEED, data, blocking=True) + + async def async_set_direction( hass, entity_id=ENTITY_MATCH_ALL, direction: str = None ) -> None: diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index a8beed73a07..05ced3b8be7 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import FanEntity, NotValidPresetModeError class BaseFan(FanEntity): @@ -17,14 +17,51 @@ def test_fanentity(): fan = BaseFan() assert fan.state == "off" assert len(fan.speed_list) == 0 + assert len(fan.preset_modes) == 0 assert fan.supported_features == 0 + assert fan.percentage_step == 1 + assert fan.speed_count == 100 assert fan.capability_attributes == {} # Test set_speed not required with pytest.raises(NotImplementedError): fan.oscillate(True) with pytest.raises(NotImplementedError): fan.set_speed("slow") + with pytest.raises(NotImplementedError): + fan.set_percentage(0) + with pytest.raises(NotValidPresetModeError): + fan.set_preset_mode("auto") with pytest.raises(NotImplementedError): fan.turn_on() with pytest.raises(NotImplementedError): fan.turn_off() + + +async def test_async_fanentity(hass): + """Test async fan entity methods.""" + fan = BaseFan() + fan.hass = hass + assert fan.state == "off" + assert len(fan.speed_list) == 0 + assert len(fan.preset_modes) == 0 + assert fan.supported_features == 0 + assert fan.percentage_step == 1 + assert fan.speed_count == 100 + assert fan.capability_attributes == {} + # Test set_speed not required + with pytest.raises(NotImplementedError): + await fan.async_oscillate(True) + with pytest.raises(NotImplementedError): + await fan.async_set_speed("slow") + with pytest.raises(NotImplementedError): + await fan.async_set_percentage(0) + with pytest.raises(NotValidPresetModeError): + await fan.async_set_preset_mode("auto") + with pytest.raises(NotImplementedError): + await fan.async_turn_on() + with pytest.raises(NotImplementedError): + await fan.async_turn_off() + with pytest.raises(NotImplementedError): + await fan.async_increase_speed() + with pytest.raises(NotImplementedError): + await fan.async_decrease_speed() diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 307ba577de3..a433bbe9935 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -1,6 +1,5 @@ """The tests for the feedreader component.""" from datetime import timedelta -from logging import getLogger from os import remove from os.path import exists import time @@ -24,8 +23,6 @@ from homeassistant.setup import setup_component from tests.common import get_test_home_assistant, load_fixture -_LOGGER = getLogger(__name__) - URL = "http://some.rss.local/rss_feed.xml" VALID_CONFIG_1 = {feedreader.DOMAIN: {CONF_URLS: [URL]}} VALID_CONFIG_2 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_SCAN_INTERVAL: 60}} diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 149cbdae4e2..ffbf7a569f9 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -345,12 +345,12 @@ async def mock_api_object_fixture(hass, config_entry, get_request_return_values) async def test_unload_config_entry(hass, config_entry, mock_api_object): - """Test the player is removed when the config entry is unloaded.""" + """Test the player is set unavailable when the config entry is unloaded.""" assert hass.states.get(TEST_MASTER_ENTITY_NAME) assert hass.states.get(TEST_ZONE_ENTITY_NAMES[0]) await config_entry.async_unload(hass) - assert not hass.states.get(TEST_MASTER_ENTITY_NAME) - assert not hass.states.get(TEST_ZONE_ENTITY_NAMES[0]) + assert hass.states.get(TEST_MASTER_ENTITY_NAME).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ZONE_ENTITY_NAMES[0]).state == STATE_UNAVAILABLE def test_master_state(hass, mock_api_object): diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index f7150df7efc..197be7bd3a6 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for the Freebox config flow.""" from unittest.mock import AsyncMock, patch -from aiofreepybox.exceptions import ( +from freebox_api.exceptions import ( AuthorizationError, HttpRequestError, InvalidTokenError, diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 31a9f89ce48..f07a78e30de 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -12,11 +12,19 @@ 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.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from homeassistant.helpers.typing import HomeAssistantType from . import 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", @@ -35,15 +43,15 @@ def fritz_fixture() -> Mock: async def test_user(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by user.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -56,9 +64,9 @@ async def test_user_auth_failed(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = [LoginError("Boom"), mock.DEFAULT] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -68,33 +76,109 @@ async def test_user_not_successful(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" async def test_user_already_configured(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by user when already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not result["result"].unique_id result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" +async def test_reauth_success(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow.""" + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert mock_config.data[CONF_USERNAME] == "other_fake_user" + assert mock_config.data[CONF_PASSWORD] == "other_fake_password" + + +async def test_reauth_auth_failed(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow with authentication failure.""" + fritz().login.side_effect = LoginError("Boom") + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == "invalid_auth" + + +async def test_reauth_not_successful(hass: HomeAssistantType, fritz: Mock): + """Test starting a reauthentication flow but no connection found.""" + fritz().login.side_effect = OSError("Boom") + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=mock_config + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + async def test_import(hass: HomeAssistantType, fritz: Mock): """Test starting a flow by import.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -105,16 +189,16 @@ async def test_import(hass: HomeAssistantType, fritz: Mock): async def test_ssdp(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_name" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -127,16 +211,16 @@ async def test_ssdp_no_friendly_name(hass: HomeAssistantType, fritz: Mock): MOCK_NO_NAME = MOCK_SSDP_DATA.copy() del MOCK_NO_NAME[ATTR_UPNP_FRIENDLY_NAME] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_NO_NAME + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_NAME ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "fake_host" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" @@ -149,9 +233,9 @@ async def test_ssdp_auth_failed(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = LoginError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" assert result["errors"] == {} @@ -159,7 +243,7 @@ async def test_ssdp_auth_failed(hass: HomeAssistantType, fritz: Mock): result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" assert result["errors"]["base"] == "invalid_auth" @@ -169,16 +253,16 @@ async def test_ssdp_not_successful(hass: HomeAssistantType, fritz: Mock): fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" @@ -187,62 +271,62 @@ async def test_ssdp_not_supported(hass: HomeAssistantType, fritz: Mock): fritz().get_device_elements.side_effect = HTTPError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "whatever", CONF_USERNAME: "whatever"}, ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "not_supported" async def test_ssdp_already_in_progress_unique_id(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_in_progress" async def test_ssdp_already_in_progress_host(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery twice.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" + 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] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_NO_UNIQUE_ID + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) - assert result["type"] == "abort" + assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_in_progress" async def test_ssdp_already_configured(hass: HomeAssistantType, fritz: Mock): """Test starting a flow from discovery when already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA ) - assert result["type"] == "create_entry" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not result["result"].unique_id result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result2["type"] == "abort" + assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" assert result["result"].unique_id == "only-a-test" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 11067c1aa51..08655033f4d 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -4,7 +4,13 @@ from unittest.mock import Mock, call from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.helpers.typing import HomeAssistantType from homeassistant.setup import async_setup_component @@ -45,8 +51,8 @@ async def test_setup_duplicate_config(hass: HomeAssistantType, fritz: Mock, capl assert "duplicate host entries found" in caplog.text -async def test_unload(hass: HomeAssistantType, fritz: Mock): - """Test unload of integration.""" +async def test_unload_remove(hass: HomeAssistantType, fritz: Mock): + """Test unload and remove of integration.""" fritz().get_devices.return_value = [FritzDeviceSwitchMock()] entity_id = f"{SWITCH_DOMAIN}.fake_name" @@ -70,6 +76,14 @@ async def test_unload(hass: HomeAssistantType, fritz: Mock): await hass.config_entries.async_unload(entry.entry_id) + assert fritz().logout.call_count == 1 + assert entry.state == ENTRY_STATE_NOT_LOADED + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert fritz().logout.call_count == 1 assert entry.state == ENTRY_STATE_NOT_LOADED state = hass.states.get(entity_id) diff --git a/tests/components/fritzbox_callmonitor/test_config_flow.py b/tests/components/fritzbox_callmonitor/test_config_flow.py index 00bc1e18679..cde30b615eb 100644 --- a/tests/components/fritzbox_callmonitor/test_config_flow.py +++ b/tests/components/fritzbox_callmonitor/test_config_flow.py @@ -14,12 +14,12 @@ from homeassistant.components.fritzbox_callmonitor.const import ( CONF_PHONEBOOK, CONF_PREFIXES, DOMAIN, - FRITZ_ATTR_NAME, FRITZ_ATTR_SERIAL_NUMBER, SERIAL_NUMBER, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import ( + ATTR_NAME, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -69,8 +69,8 @@ MOCK_YAML_CONFIG = { CONF_NAME: MOCK_NAME, } MOCK_DEVICE_INFO = {FRITZ_ATTR_SERIAL_NUMBER: MOCK_SERIAL_NUMBER} -MOCK_PHONEBOOK_INFO_1 = {FRITZ_ATTR_NAME: MOCK_PHONEBOOK_NAME_1} -MOCK_PHONEBOOK_INFO_2 = {FRITZ_ATTR_NAME: MOCK_PHONEBOOK_NAME_2} +MOCK_PHONEBOOK_INFO_1 = {ATTR_NAME: MOCK_PHONEBOOK_NAME_1} +MOCK_PHONEBOOK_INFO_2 = {ATTR_NAME: MOCK_PHONEBOOK_NAME_2} MOCK_UNIQUE_ID = f"{MOCK_SERIAL_NUMBER}-{MOCK_PHONEBOOK_ID}" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 5ae8d707cb1..0e8e31bb20d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -33,44 +33,67 @@ CONFIG_THEMES = { @pytest.fixture -def mock_http_client(hass, aiohttp_client): - """Start the Home Assistant HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) +async def ignore_frontend_deps(hass): + """Frontend dependencies.""" + frontend = await async_get_integration(hass, "frontend") + for dep in frontend.dependencies: + if dep not in ("http", "websocket_api"): + hass.config.components.add(dep) @pytest.fixture -def mock_http_client_with_themes(hass, aiohttp_client): - """Start the Home Assistant HTTP component.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - "frontend", - {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}}, - ) +async def frontend(hass, ignore_frontend_deps): + """Frontend setup with themes.""" + assert await async_setup_component( + hass, + "frontend", + {}, ) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture -def mock_http_client_with_urls(hass, aiohttp_client): - """Start the Home Assistant HTTP component.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - "frontend", - { - DOMAIN: { - CONF_JS_VERSION: "auto", - CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], - CONF_EXTRA_HTML_URL_ES5: [ - "https://domain.com/my_extra_url_es5.html" - ], - } - }, - ) +async def frontend_themes(hass): + """Frontend setup with themes.""" + assert await async_setup_component( + hass, + "frontend", + CONFIG_THEMES, ) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture +async def mock_http_client(hass, aiohttp_client, frontend): + """Start the Home Assistant HTTP component.""" + return await aiohttp_client(hass.http.app) + + +@pytest.fixture +async def themes_ws_client(hass, hass_ws_client, frontend_themes): + """Start the Home Assistant HTTP component.""" + return await hass_ws_client(hass) + + +@pytest.fixture +async def ws_client(hass, hass_ws_client, frontend): + """Start the Home Assistant HTTP component.""" + return await hass_ws_client(hass) + + +@pytest.fixture +async def mock_http_client_with_urls(hass, aiohttp_client, ignore_frontend_deps): + """Start the Home Assistant HTTP component.""" + assert await async_setup_component( + hass, + "frontend", + { + DOMAIN: { + CONF_JS_VERSION: "auto", + CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], + CONF_EXTRA_HTML_URL_ES5: ["https://domain.com/my_extra_url_es5.html"], + } + }, + ) + return await aiohttp_client(hass.http.app) @pytest.fixture @@ -118,13 +141,10 @@ async def test_we_cannot_POST_to_root(mock_http_client): assert resp.status == 405 -async def test_themes_api(hass, hass_ws_client): +async def test_themes_api(hass, themes_ws_client): """Test that /api/themes returns correct data.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) - - await client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "default" assert msg["result"]["default_dark_theme"] is None @@ -135,8 +155,8 @@ async def test_themes_api(hass, hass_ws_client): # safe mode hass.config.safe_mode = True - await client.send_json({"id": 6, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "safe_mode" assert msg["result"]["themes"] == { @@ -144,9 +164,8 @@ async def test_themes_api(hass, hass_ws_client): } -async def test_themes_persist(hass, hass_ws_client, hass_storage): +async def test_themes_persist(hass, hass_storage, hass_ws_client, ignore_frontend_deps): """Test that theme settings are restores after restart.""" - hass_storage[THEMES_STORAGE_KEY] = { "key": THEMES_STORAGE_KEY, "version": 1, @@ -157,26 +176,18 @@ async def test_themes_persist(hass, hass_ws_client, hass_storage): } assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) + themes_ws_client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "happy" assert msg["result"]["default_dark_theme"] == "dark" -async def test_themes_save_storage(hass, hass_storage): +async def test_themes_save_storage(hass, hass_storage, frontend_themes): """Test that theme settings are restores after restart.""" - hass_storage[THEMES_STORAGE_KEY] = { - "key": THEMES_STORAGE_KEY, - "version": 1, - "data": {}, - } - - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - await hass.services.async_call( DOMAIN, "set_theme", {"name": "happy"}, blocking=True ) @@ -196,17 +207,14 @@ async def test_themes_save_storage(hass, hass_storage): } -async def test_themes_set_theme(hass, hass_ws_client): +async def test_themes_set_theme(hass, themes_ws_client): """Test frontend.set_theme service.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) - await hass.services.async_call( DOMAIN, "set_theme", {"name": "happy"}, blocking=True ) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "happy" @@ -214,8 +222,8 @@ async def test_themes_set_theme(hass, hass_ws_client): DOMAIN, "set_theme", {"name": "default"}, blocking=True ) - await client.send_json({"id": 6, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "default" @@ -225,39 +233,35 @@ async def test_themes_set_theme(hass, hass_ws_client): await hass.services.async_call(DOMAIN, "set_theme", {"name": "none"}, blocking=True) - await client.send_json({"id": 7, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "default" -async def test_themes_set_theme_wrong_name(hass, hass_ws_client): +async def test_themes_set_theme_wrong_name(hass, themes_ws_client): """Test frontend.set_theme service called with wrong name.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) await hass.services.async_call( DOMAIN, "set_theme", {"name": "wrong"}, blocking=True ) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + msg = await themes_ws_client.receive_json() assert msg["result"]["default_theme"] == "default" -async def test_themes_set_dark_theme(hass, hass_ws_client): +async def test_themes_set_dark_theme(hass, themes_ws_client): """Test frontend.set_theme service called with dark mode.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) await hass.services.async_call( DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True ) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_dark_theme"] == "dark" @@ -265,8 +269,8 @@ async def test_themes_set_dark_theme(hass, hass_ws_client): DOMAIN, "set_theme", {"name": "default", "mode": "dark"}, blocking=True ) - await client.send_json({"id": 6, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_dark_theme"] == "default" @@ -274,32 +278,27 @@ async def test_themes_set_dark_theme(hass, hass_ws_client): DOMAIN, "set_theme", {"name": "none", "mode": "dark"}, blocking=True ) - await client.send_json({"id": 7, "type": "frontend/get_themes"}) - msg = await client.receive_json() + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() assert msg["result"]["default_dark_theme"] is None -async def test_themes_set_dark_theme_wrong_name(hass, hass_ws_client): +async def test_themes_set_dark_theme_wrong_name(hass, frontend, themes_ws_client): """Test frontend.set_theme service called with mode dark and wrong name.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) - await hass.services.async_call( DOMAIN, "set_theme", {"name": "wrong", "mode": "dark"}, blocking=True ) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + msg = await themes_ws_client.receive_json() assert msg["result"]["default_dark_theme"] is None -async def test_themes_reload_themes(hass, hass_ws_client): +async def test_themes_reload_themes(hass, frontend, themes_ws_client): """Test frontend.reload_themes service.""" - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) - client = await hass_ws_client(hass) with patch( "homeassistant.components.frontend.async_hass_config_yaml", @@ -310,22 +309,19 @@ async def test_themes_reload_themes(hass, hass_ws_client): ) await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) - msg = await client.receive_json() + msg = await themes_ws_client.receive_json() assert msg["result"]["themes"] == {"sad": {"primary-color": "blue"}} assert msg["result"]["default_theme"] == "default" -async def test_missing_themes(hass, hass_ws_client): +async def test_missing_themes(hass, ws_client): """Test that themes API works when themes are not defined.""" - await async_setup_component(hass, "frontend", {}) + await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) - client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "frontend/get_themes"}) - - msg = await client.receive_json() + msg = await ws_client.receive_json() assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT @@ -372,10 +368,10 @@ async def test_get_panels(hass, hass_ws_client, mock_http_client): assert len(events) == 2 -async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): +async def test_get_panels_non_admin(hass, ws_client, hass_admin_user): """Test get_panels command.""" hass_admin_user.groups = [] - await async_setup_component(hass, "frontend", {}) + hass.components.frontend.async_register_built_in_panel( "map", "Map", "mdi:tooltip-account", require_admin=True ) @@ -383,10 +379,9 @@ async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): "history", "History", "mdi:history" ) - client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "get_panels"}) + await ws_client.send_json({"id": 5, "type": "get_panels"}) - msg = await client.receive_json() + msg = await ws_client.receive_json() assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT @@ -395,18 +390,15 @@ async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): assert "map" not in msg["result"] -async def test_get_translations(hass, hass_ws_client): +async def test_get_translations(hass, ws_client): """Test get_translations command.""" - await async_setup_component(hass, "frontend", {}) - client = await hass_ws_client(hass) - with patch( "homeassistant.components.frontend.async_get_translations", side_effect=lambda hass, lang, category, integration, config_flow: { "lang": lang }, ): - await client.send_json( + await ws_client.send_json( { "id": 5, "type": "frontend/get_translations", @@ -414,7 +406,7 @@ async def test_get_translations(hass, hass_ws_client): "category": "lang", } ) - msg = await client.receive_json() + msg = await ws_client.receive_json() assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT @@ -422,16 +414,16 @@ async def test_get_translations(hass, hass_ws_client): assert msg["result"] == {"resources": {"lang": "nl"}} -async def test_auth_load(mock_http_client, mock_onboarded): +async def test_auth_load(hass): """Test auth component loaded by default.""" - resp = await mock_http_client.get("/auth/providers") - assert resp.status == 200 + frontend = await async_get_integration(hass, "frontend") + assert "auth" in frontend.dependencies -async def test_onboarding_load(mock_http_client): +async def test_onboarding_load(hass): """Test onboarding component loaded by default.""" - resp = await mock_http_client.get("/api/onboarding") - assert resp.status == 200 + frontend = await async_get_integration(hass, "frontend") + assert "onboarding" in frontend.dependencies async def test_auth_authorize(mock_http_client): @@ -457,7 +449,7 @@ async def test_auth_authorize(mock_http_client): assert "public" in resp.headers.get("cache-control") -async def test_get_version(hass, hass_ws_client): +async def test_get_version(hass, ws_client): """Test get_version command.""" frontend = await async_get_integration(hass, "frontend") cur_version = next( @@ -466,11 +458,8 @@ async def test_get_version(hass, hass_ws_client): if req.startswith("home-assistant-frontend==") ) - await async_setup_component(hass, "frontend", {}) - client = await hass_ws_client(hass) - - await client.send_json({"id": 5, "type": "frontend/get_version"}) - msg = await client.receive_json() + await ws_client.send_json({"id": 5, "type": "frontend/get_version"}) + msg = await ws_client.receive_json() assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 9a147995541..3e2f07b446b 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -3,6 +3,8 @@ import asyncio from os import path from unittest.mock import patch +import respx + from homeassistant import config as hass_config from homeassistant.components.generic import DOMAIN from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -14,9 +16,10 @@ from homeassistant.const import ( from homeassistant.setup import async_setup_component -async def test_fetching_url(aioclient_mock, hass, hass_client): +@respx.mock +async def test_fetching_url(hass, hass_client): """Test that it fetches the given url.""" - aioclient_mock.get("http://example.com", text="hello world") + respx.get("http://example.com").respond(text="hello world") await async_setup_component( hass, @@ -38,12 +41,12 @@ async def test_fetching_url(aioclient_mock, hass, hass_client): resp = await client.get("/api/camera_proxy/camera.config_test") assert resp.status == 200 - assert aioclient_mock.call_count == 1 + assert respx.calls.call_count == 1 body = await resp.text() assert body == "hello world" resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 2 + assert respx.calls.call_count == 2 async def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): @@ -100,12 +103,13 @@ async def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client): assert resp.status == 200 -async def test_limit_refetch(aioclient_mock, hass, hass_client): +@respx.mock +async def test_limit_refetch(hass, hass_client): """Test that it fetches the given url.""" - aioclient_mock.get("http://example.com/5a", text="hello world") - aioclient_mock.get("http://example.com/10a", text="hello world") - aioclient_mock.get("http://example.com/15a", text="hello planet") - aioclient_mock.get("http://example.com/20a", status=HTTP_NOT_FOUND) + respx.get("http://example.com/5a").respond(text="hello world") + respx.get("http://example.com/10a").respond(text="hello world") + respx.get("http://example.com/15a").respond(text="hello planet") + respx.get("http://example.com/20a").respond(status_code=HTTP_NOT_FOUND) await async_setup_component( hass, @@ -129,19 +133,19 @@ async def test_limit_refetch(aioclient_mock, hass, hass_client): with patch("async_timeout.timeout", side_effect=asyncio.TimeoutError()): resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 0 + assert respx.calls.call_count == 0 assert resp.status == HTTP_INTERNAL_SERVER_ERROR hass.states.async_set("sensor.temp", "10") resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 1 + assert respx.calls.call_count == 1 assert resp.status == 200 body = await resp.text() assert body == "hello world" resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 1 + assert respx.calls.call_count == 1 assert resp.status == 200 body = await resp.text() assert body == "hello world" @@ -150,7 +154,7 @@ async def test_limit_refetch(aioclient_mock, hass, hass_client): # Url change = fetch new image resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 2 + assert respx.calls.call_count == 2 assert resp.status == 200 body = await resp.text() assert body == "hello planet" @@ -158,7 +162,7 @@ async def test_limit_refetch(aioclient_mock, hass, hass_client): # Cause a template render error hass.states.async_remove("sensor.temp") resp = await client.get("/api/camera_proxy/camera.config_test") - assert aioclient_mock.call_count == 2 + assert respx.calls.call_count == 2 assert resp.status == 200 body = await resp.text() assert body == "hello planet" @@ -176,17 +180,18 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): "still_image_url": "https://example.com", "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', "limit_refetch_to_url_change": True, - } + }, }, ) + assert await async_setup_component(hass, "stream", {}) await hass.async_block_till_done() hass.states.async_set("sensor.temp", "5") with patch( - "homeassistant.components.camera.request_stream", + "homeassistant.components.camera.Stream.endpoint_url", return_value="http://home.assistant/playlist.m3u8", - ) as mock_request_stream: + ) as mock_stream_url: # Request playlist through WebSocket client = await hass_ws_client(hass) @@ -196,25 +201,47 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): msg = await client.receive_json() # Assert WebSocket response - assert mock_request_stream.call_count == 1 - assert mock_request_stream.call_args[0][1] == "http://example.com/5a" + assert mock_stream_url.call_count == 1 assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"]["url"][-13:] == "playlist.m3u8" - # Cause a template render error - hass.states.async_remove("sensor.temp") + +async def test_stream_source_error(aioclient_mock, hass, hass_client, hass_ws_client): + """Test that the stream source has an error.""" + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + # Does not exist + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, + }, + }, + ) + assert await async_setup_component(hass, "stream", {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.camera.Stream.endpoint_url", + return_value="http://home.assistant/playlist.m3u8", + ) as mock_stream_url: + # Request playlist through WebSocket + client = await hass_ws_client(hass) await client.send_json( - {"id": 2, "type": "camera/stream", "entity_id": "camera.config_test"} + {"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"} ) msg = await client.receive_json() - # Assert that no new call to the stream request should have been made - assert mock_request_stream.call_count == 1 - # Assert the websocket error message - assert msg["id"] == 2 + # Assert WebSocket response + assert mock_stream_url.call_count == 0 + assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] is False assert msg["error"] == { @@ -223,6 +250,28 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): } +async def test_setup_alternative_options(hass, hass_ws_client): + """Test that the stream source is setup with different config options.""" + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "authentication": "digest", + "username": "user", + "password": "pass", + "stream_source": "rtsp://example.com:554/rtsp/", + "rtsp_transport": "udp", + }, + }, + ) + await hass.async_block_till_done() + assert hass.data["camera"].get_entity("camera.config_test") + + async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): """Test a stream request without stream source option set.""" assert await async_setup_component( @@ -240,7 +289,7 @@ async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_clien await hass.async_block_till_done() with patch( - "homeassistant.components.camera.request_stream", + "homeassistant.components.camera.Stream.endpoint_url", return_value="http://home.assistant/playlist.m3u8", ) as mock_request_stream: # Request playlist through WebSocket @@ -262,11 +311,12 @@ async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_clien } -async def test_camera_content_type(aioclient_mock, hass, hass_client): +@respx.mock +async def test_camera_content_type(hass, hass_client): """Test generic camera with custom content_type.""" svg_image = "" urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg" - aioclient_mock.get(urlsvg, text=svg_image) + respx.get(urlsvg).respond(text=svg_image) cam_config_svg = { "name": "config_test_svg", @@ -286,23 +336,24 @@ async def test_camera_content_type(aioclient_mock, hass, hass_client): client = await hass_client() resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg") - assert aioclient_mock.call_count == 1 + assert respx.calls.call_count == 1 assert resp_1.status == 200 assert resp_1.content_type == "image/svg+xml" body = await resp_1.text() assert body == svg_image resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg") - assert aioclient_mock.call_count == 2 + assert respx.calls.call_count == 2 assert resp_2.status == 200 assert resp_2.content_type == "image/jpeg" body = await resp_2.text() assert body == svg_image -async def test_reloading(aioclient_mock, hass, hass_client): +@respx.mock +async def test_reloading(hass, hass_client): """Test we can cleanly reload.""" - aioclient_mock.get("http://example.com", text="hello world") + respx.get("http://example.com").respond(text="hello world") await async_setup_component( hass, @@ -324,7 +375,7 @@ async def test_reloading(aioclient_mock, hass, hass_client): resp = await client.get("/api/camera_proxy/camera.config_test") assert resp.status == 200 - assert aioclient_mock.call_count == 1 + assert respx.calls.call_count == 1 body = await resp.text() assert body == "hello world" @@ -351,7 +402,7 @@ async def test_reloading(aioclient_mock, hass, hass_client): resp = await client.get("/api/camera_proxy/camera.reload") assert resp.status == 200 - assert aioclient_mock.call_count == 2 + assert respx.calls.call_count == 2 body = await resp.text() assert body == "hello world" diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 201ed0130ff..e6cdf962d24 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -159,6 +159,33 @@ async def test_heater_switch(hass, setup_comp_1): assert STATE_ON == hass.states.get(heater_switch).state +async def test_unique_id(hass, setup_comp_1): + """Test heater switching input_boolean.""" + unique_id = "some_unique_id" + _setup_sensor(hass, 18) + _setup_switch(hass, True) + assert await async_setup_component( + hass, + DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "heater": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "unique_id": unique_id, + } + }, + ) + await hass.async_block_till_done() + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entry = entity_registry.async_get(ENTITY) + assert entry + assert entry.unique_id == unique_id + + def _setup_sensor(hass, temp): """Set up the test sensor.""" hass.states.async_set(ENT_SENSOR, temp) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index cb11f1ceaac..4bef45cf0ee 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -245,6 +245,27 @@ DEMO_DEVICES = [ "type": "action.devices.types.FAN", "willReportState": False, }, + { + "id": "fan.percentage_full_fan", + "name": {"name": "Percentage Full Fan"}, + "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, + { + "id": "fan.percentage_limited_fan", + "name": {"name": "Percentage Limited Fan"}, + "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, + { + "id": "fan.preset_only_limited_fan", + "name": {"name": "Preset Only Limited Fan"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, { "id": "climate.hvac", "name": {"name": "Hvac"}, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 9c8f9a48338..9531602ef0c 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -30,7 +30,12 @@ from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import mock_area_registry, mock_device_registry, mock_registry +from tests.common import ( + async_capture_events, + mock_area_registry, + mock_device_registry, + mock_registry, +) REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -77,8 +82,7 @@ async def test_sync_message(hass): }, ) - events = [] - hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) + events = async_capture_events(hass, EVENT_SYNC_RECEIVED) result = await sh.async_handle_message( hass, @@ -192,8 +196,7 @@ async def test_sync_in_area(area_on_device, hass, registries): config = MockConfig(should_expose=lambda _: True, entity_config={}) - events = [] - hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) + events = async_capture_events(hass, EVENT_SYNC_RECEIVED) result = await sh.async_handle_message( hass, @@ -295,8 +298,7 @@ async def test_query_message(hass): light3.entity_id = "light.color_temp_light" await light3.async_update_ha_state() - events = [] - hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append) + events = async_capture_events(hass, EVENT_QUERY_RECEIVED) result = await sh.async_handle_message( hass, @@ -387,11 +389,8 @@ async def test_execute(hass): "light", "turn_off", {"entity_id": "light.ceiling_lights"}, blocking=True ) - events = [] - hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) - - service_events = [] - hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append) + events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) + service_events = async_capture_events(hass, EVENT_CALL_SERVICE) result = await sh.async_handle_message( hass, @@ -570,8 +569,7 @@ async def test_raising_error_trait(hass): {ATTR_MIN_TEMP: 15, ATTR_MAX_TEMP: 30, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - events = [] - hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) + events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) await hass.async_block_till_done() result = await sh.async_handle_message( @@ -660,8 +658,7 @@ async def test_unavailable_state_does_sync(hass): light._available = False # pylint: disable=protected-access await light.async_update_ha_state() - events = [] - hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) + events = async_capture_events(hass, EVENT_SYNC_RECEIVED) result = await sh.async_handle_message( hass, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 9b573f1cf71..ba189020513 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -54,7 +54,7 @@ from homeassistant.util import color from . import BASIC_CONFIG, MockConfig -from tests.common import async_mock_service +from tests.common import async_capture_events, async_mock_service REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -84,8 +84,7 @@ async def test_brightness_light(hass): assert trt.query_attributes() == {"brightness": 95} - events = [] - hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + events = async_capture_events(hass, EVENT_CALL_SERVICE) calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) await trt.execute( @@ -1391,6 +1390,7 @@ async def test_fan_speed(hass): fan.SPEED_HIGH, ], "speed": "low", + "percentage": 33, }, ), BASIC_CONFIG, @@ -1438,11 +1438,13 @@ async def test_fan_speed(hass): ], }, "reversible": False, + "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { "currentFanSpeedSetting": "low", "on": True, + "currentFanSpeedPercent": 33, } assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) @@ -1453,6 +1455,14 @@ async def test_fan_speed(hass): assert len(calls) == 1 assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} + assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeedPercent": 10}) + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_PERCENTAGE) + await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeedPercent": 10}, {}) + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": "fan.living_room_fan", "percentage": 10} + async def test_climate_fan_speed(hass): """Test FanSpeed trait speed control support for climate domain.""" @@ -1495,6 +1505,7 @@ async def test_climate_fan_speed(hass): ], }, "reversible": False, + "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { @@ -1921,14 +1932,18 @@ async def test_openclose_cover(hass): assert trt.sync_attributes() == {} assert trt.query_attributes() == {"openPercent": 75} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls_set = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls_open = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) await trt.execute( trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 50}, {} ) - assert len(calls) == 2 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} - assert calls[1].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 100} + assert len(calls_set) == 1 + assert calls_set[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} + + assert len(calls_open) == 1 + assert calls_open[0].data == {ATTR_ENTITY_ID: "cover.bla"} async def test_openclose_cover_unknown_state(hass): @@ -2099,6 +2114,7 @@ async def test_openclose_cover_secure(hass, device_class): assert trt.query_attributes() == {"openPercent": 75} calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls_close = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) # No challenge data with pytest.raises(error.ChallengeNeeded) as err: @@ -2124,8 +2140,8 @@ async def test_openclose_cover_secure(hass, device_class): # no challenge on close await trt.execute(trait.COMMAND_OPENCLOSE, PIN_DATA, {"openPercent": 0}, {}) - assert len(calls) == 2 - assert calls[1].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 0} + assert len(calls_close) == 1 + assert calls_close[0].data == {ATTR_ENTITY_ID: "cover.bla"} @pytest.mark.parametrize( diff --git a/tests/components/habitica/__init__.py b/tests/components/habitica/__init__.py new file mode 100644 index 00000000000..a7f62afff8f --- /dev/null +++ b/tests/components/habitica/__init__.py @@ -0,0 +1 @@ +"""Tests for the habitica integration.""" diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py new file mode 100644 index 00000000000..d02a9031d63 --- /dev/null +++ b/tests/components/habitica/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the habitica config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientResponseError + +from homeassistant import config_entries, setup +from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN + +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"] == "form" + assert result["errors"] == {} + + mock_obj = MagicMock() + mock_obj.user.get = AsyncMock() + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ), patch( + "homeassistant.components.habitica.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.habitica.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_user": "test-api-user", "api_key": "test-api-key"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Default username" + assert result2["data"] == { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_credentials(hass): + """Test we handle invalid credentials error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_obj = MagicMock() + mock_obj.user.get = AsyncMock(side_effect=ClientResponseError(MagicMock(), ())) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_credentials"} + + +async def test_form_unexpected_exception(hass): + """Test we handle unexpected exception error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_obj = MagicMock() + mock_obj.user.get = AsyncMock(side_effect=Exception) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_manual_flow_config_exist(hass): + """Test config flow discovers only already configured config.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="test-api-user", + data={"api_user": "test-api-user", "api_key": "test-api-key"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_obj = MagicMock() + mock_obj.user.get = AsyncMock(return_value={"api_user": "test-api-user"}) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py new file mode 100644 index 00000000000..5f7e4b7fbf5 --- /dev/null +++ b/tests/components/habitica/test_init.py @@ -0,0 +1,36 @@ +"""Test the habitica init module.""" +from homeassistant.components.habitica.const import ( + DEFAULT_URL, + DOMAIN, + SERVICE_API_CALL, +) + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload(hass, aioclient_mock): + """Test integration setup and unload.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-api-user", + data={ + "api_user": "test-api-user", + "api_key": "test-api-key", + "url": DEFAULT_URL, + }, + ) + entry.add_to_hass(hass) + + aioclient_mock.get( + "https://habitica.com/api/v3/user", + json={"data": {"api_user": "test-api-user", "profile": {"name": "test_user"}}}, + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_API_CALL) + + assert await hass.config_entries.async_unload(entry.entry_id) + + assert not hass.services.has_service(DOMAIN, SERVICE_API_CALL) diff --git a/tests/components/harmony/conftest.py b/tests/components/harmony/conftest.py index cde8c43fe89..29e897916b9 100644 --- a/tests/components/harmony/conftest.py +++ b/tests/components/harmony/conftest.py @@ -1,5 +1,4 @@ """Fixtures for harmony tests.""" -import logging from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from aioharmony.const import ClientCallbackType @@ -7,21 +6,20 @@ import pytest from homeassistant.components.harmony.const import ACTIVITY_POWER_OFF -_LOGGER = logging.getLogger(__name__) - -WATCH_TV_ACTIVITY_ID = 123 -PLAY_MUSIC_ACTIVITY_ID = 456 +from .const import NILE_TV_ACTIVITY_ID, PLAY_MUSIC_ACTIVITY_ID, WATCH_TV_ACTIVITY_ID ACTIVITIES_TO_IDS = { ACTIVITY_POWER_OFF: -1, "Watch TV": WATCH_TV_ACTIVITY_ID, "Play Music": PLAY_MUSIC_ACTIVITY_ID, + "Nile-TV": NILE_TV_ACTIVITY_ID, } IDS_TO_ACTIVITIES = { -1: ACTIVITY_POWER_OFF, WATCH_TV_ACTIVITY_ID: "Watch TV", PLAY_MUSIC_ACTIVITY_ID: "Play Music", + NILE_TV_ACTIVITY_ID: "Nile-TV", } TV_DEVICE_ID = 1234 @@ -111,6 +109,7 @@ class FakeHarmonyClient: return_value=[ {"name": "Watch TV", "id": WATCH_TV_ACTIVITY_ID}, {"name": "Play Music", "id": PLAY_MUSIC_ACTIVITY_ID}, + {"name": "Nile-TV", "id": NILE_TV_ACTIVITY_ID}, ] ) type(config).devices = PropertyMock( @@ -121,8 +120,11 @@ class FakeHarmonyClient: type(config).config = PropertyMock( return_value={ "activity": [ + {"id": 10000, "label": None}, + {"id": -1, "label": "PowerOff"}, {"id": WATCH_TV_ACTIVITY_ID, "label": "Watch TV"}, {"id": PLAY_MUSIC_ACTIVITY_ID, "label": "Play Music"}, + {"id": NILE_TV_ACTIVITY_ID, "label": "Nile-TV"}, ] } ) diff --git a/tests/components/harmony/const.py b/tests/components/harmony/const.py index 1911ea949af..488fe30dec3 100644 --- a/tests/components/harmony/const.py +++ b/tests/components/harmony/const.py @@ -4,3 +4,8 @@ HUB_NAME = "Guest Room" ENTITY_REMOTE = "remote.guest_room" ENTITY_WATCH_TV = "switch.guest_room_watch_tv" ENTITY_PLAY_MUSIC = "switch.guest_room_play_music" +ENTITY_NILE_TV = "switch.guest_room_nile_tv" + +WATCH_TV_ACTIVITY_ID = 123 +PLAY_MUSIC_ACTIVITY_ID = 456 +NILE_TV_ACTIVITY_ID = 789 diff --git a/tests/components/harmony/test_activity_changes.py b/tests/components/harmony/test_activity_changes.py index ff76c3ce998..dbbc6beef5b 100644 --- a/tests/components/harmony/test_activity_changes.py +++ b/tests/components/harmony/test_activity_changes.py @@ -1,7 +1,4 @@ """Test the Logitech Harmony Hub activity switches.""" - -import logging - from homeassistant.components.harmony.const import DOMAIN from homeassistant.components.remote import ATTR_ACTIVITY, DOMAIN as REMOTE_DOMAIN from homeassistant.components.switch import ( @@ -22,8 +19,6 @@ from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME from tests.common import MockConfigEntry -_LOGGER = logging.getLogger(__name__) - async def test_switch_toggles(mock_hc, hass, mock_write_config): """Ensure calls to the switch modify the harmony state.""" diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py new file mode 100644 index 00000000000..c63727f8738 --- /dev/null +++ b/tests/components/harmony/test_init.py @@ -0,0 +1,72 @@ +"""Test init of Logitch Harmony Hub integration.""" +from homeassistant.components.harmony.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .const import ( + ENTITY_NILE_TV, + ENTITY_PLAY_MUSIC, + ENTITY_WATCH_TV, + HUB_NAME, + NILE_TV_ACTIVITY_ID, + PLAY_MUSIC_ACTIVITY_ID, + WATCH_TV_ACTIVITY_ID, +) + +from tests.common import MockConfigEntry, mock_registry + + +async def test_unique_id_migration(mock_hc, hass, mock_write_config): + """Test migration of switch unique ids to stable ones.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + mock_registry( + hass, + { + # old format + ENTITY_WATCH_TV: entity_registry.RegistryEntry( + entity_id=ENTITY_WATCH_TV, + unique_id="123443-Watch TV", + platform="harmony", + config_entry_id=entry.entry_id, + ), + # old format, activity name with - + ENTITY_NILE_TV: entity_registry.RegistryEntry( + entity_id=ENTITY_NILE_TV, + unique_id="123443-Nile-TV", + platform="harmony", + config_entry_id=entry.entry_id, + ), + # new format + ENTITY_PLAY_MUSIC: entity_registry.RegistryEntry( + entity_id=ENTITY_PLAY_MUSIC, + unique_id=f"activity_{PLAY_MUSIC_ACTIVITY_ID}", + platform="harmony", + config_entry_id=entry.entry_id, + ), + # old entity which no longer has a matching activity on the hub. skipped. + "switch.some_other_activity": entity_registry.RegistryEntry( + entity_id="switch.some_other_activity", + unique_id="123443-Some Other Activity", + platform="harmony", + config_entry_id=entry.entry_id, + ), + }, + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ent_reg = await entity_registry.async_get_registry(hass) + + switch_tv = ent_reg.async_get(ENTITY_WATCH_TV) + assert switch_tv.unique_id == f"activity_{WATCH_TV_ACTIVITY_ID}" + + switch_nile = ent_reg.async_get(ENTITY_NILE_TV) + assert switch_nile.unique_id == f"activity_{NILE_TV_ACTIVITY_ID}" + + switch_music = ent_reg.async_get(ENTITY_PLAY_MUSIC) + assert switch_music.unique_id == f"activity_{PLAY_MUSIC_ACTIVITY_ID}" diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py index ad9829f17ff..f3f35b62562 100644 --- a/tests/components/hassio/__init__.py +++ b/tests/components/hassio/__init__.py @@ -1,3 +1,48 @@ """Tests for Hass.io component.""" +import pytest HASSIO_TOKEN = "123456" + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock): + """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/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"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 7ed24dca457..2efb5b0744e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -2,60 +2,16 @@ import os from unittest.mock import patch -import pytest - from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import frontend from homeassistant.components.hassio import STORAGE_KEY from homeassistant.setup import async_setup_component +from . import mock_all # noqa + MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} -@pytest.fixture(autouse=True) -def mock_all(aioclient_mock): - """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/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"}}, - ) - aioclient_mock.get( - "http://127.0.0.1/os/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, - ) - aioclient_mock.get( - "http://127.0.0.1/supervisor/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, - ) - aioclient_mock.get( - "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} - ) - - async def test_setup_api_ping(hass, aioclient_mock): """Test setup with API ping.""" with patch.dict(os.environ, MOCK_ENVIRON): diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py new file mode 100644 index 00000000000..18da5df13ea --- /dev/null +++ b/tests/components/hassio/test_websocket_api.py @@ -0,0 +1,90 @@ +"""Test websocket API.""" +from homeassistant.components.hassio.const import ( + ATTR_DATA, + ATTR_ENDPOINT, + ATTR_METHOD, + ATTR_WS_EVENT, + EVENT_SUPERVISOR_EVENT, + WS_ID, + WS_TYPE, + WS_TYPE_API, + WS_TYPE_SUBSCRIBE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from . import mock_all # noqa + +from tests.common import async_mock_signal + + +async def test_ws_subscription(hassio_env, hass: HomeAssistant, hass_ws_client): + """Test websocket subscription.""" + assert await async_setup_component(hass, "hassio", {}) + client = await hass_ws_client(hass) + await client.send_json({WS_ID: 5, WS_TYPE: WS_TYPE_SUBSCRIBE}) + response = await client.receive_json() + assert response["success"] + + calls = async_mock_signal(hass, EVENT_SUPERVISOR_EVENT) + async_dispatcher_send(hass, EVENT_SUPERVISOR_EVENT, {"lorem": "ipsum"}) + + response = await client.receive_json() + assert response["event"]["lorem"] == "ipsum" + assert len(calls) == 1 + + await client.send_json( + { + WS_ID: 6, + WS_TYPE: "supervisor/event", + ATTR_DATA: {ATTR_WS_EVENT: "test", "lorem": "ipsum"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert len(calls) == 2 + + response = await client.receive_json() + assert response["event"]["lorem"] == "ipsum" + + # Unsubscribe + await client.send_json({WS_ID: 7, WS_TYPE: "unsubscribe_events", "subscription": 5}) + response = await client.receive_json() + assert response["success"] + + +async def test_websocket_supervisor_api( + hassio_env, hass: HomeAssistant, hass_ws_client, aioclient_mock +): + """Test Supervisor websocket api.""" + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + aioclient_mock.post( + "http://127.0.0.1/snapshots/new/partial", + json={"result": "ok", "data": {"slug": "sn_slug"}}, + ) + + await websocket_client.send_json( + { + WS_ID: 1, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/snapshots/new/partial", + ATTR_METHOD: "post", + } + ) + + msg = await websocket_client.receive_json() + assert msg["result"]["slug"] == "sn_slug" + + await websocket_client.send_json( + { + WS_ID: 2, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/supervisor/info", + ATTR_METHOD: "get", + } + ) + + msg = await websocket_client.receive_json() + assert msg["result"]["version_latest"] == "1.0.0" diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index ef7285ab185..4d979f8e556 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -587,10 +587,10 @@ async def test_select_input_command_error( async def test_unload_config_entry(hass, config_entry, config, controller): - """Test the player is removed when the config entry is unloaded.""" + """Test the player is set unavailable when the config entry is unloaded.""" await setup_platform(hass, config_entry, config) await config_entry.async_unload(hass) - assert not hass.states.get("media_player.test_player") + assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE async def test_play_media_url(hass, config_entry, config, controller, caplog): diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 30985432718..610bc371b25 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -8,17 +8,14 @@ from homeassistant.components.homeassistant import scene as ha_scene from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import async_capture_events, async_mock_service async def test_reload_config_service(hass): """Test the reload config service.""" assert await async_setup_component(hass, "scene", {}) - test_reloaded_event = [] - hass.bus.async_listen( - EVENT_SCENE_RELOADED, lambda event: test_reloaded_event.append(event) - ) + test_reloaded_event = async_capture_events(hass, EVENT_SCENE_RELOADED) with patch( "homeassistant.config.load_yaml_config_file", diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 8fedaac3815..f1ff3564065 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -17,7 +17,7 @@ def calls(hass): @pytest.fixture def context_with_user(): - """Track calls to a mock service.""" + """Create a context with default user_id.""" return Context(user_id="test_user_id") @@ -59,6 +59,39 @@ async def test_if_fires_on_event(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_templated_event(hass, calls): + """Test the firing of events.""" + context = Context() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"event_type": "test_event"}, + "trigger": {"platform": "event", "event_type": "{{event_type}}"}, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.bus.async_fire("test_event", context=context) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].context.parent_id == context.id + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, + blocking=True, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_multiple_events(hass, calls): """Test the firing of events.""" context = Context() @@ -161,6 +194,58 @@ async def test_if_fires_on_event_with_data_and_context(hass, calls, context_with assert len(calls) == 1 +async def test_if_fires_on_event_with_templated_data_and_context( + hass, calls, context_with_user +): + """Test the firing of events with templated data and context.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": { + "attr_1_val": "milk", + "attr_2_val": "beer", + "user_id": context_with_user.user_id, + }, + "trigger": { + "platform": "event", + "event_type": "test_event", + "event_data": { + "attr_1": "{{attr_1_val}}", + "attr_2": "{{attr_2_val}}", + }, + "context": {"user_id": "{{user_id}}"}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.bus.async_fire( + "test_event", + {"attr_1": "milk", "another": "value", "attr_2": "beer"}, + context=context_with_user, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + hass.bus.async_fire( + "test_event", + {"attr_1": "milk", "another": "value"}, + context=context_with_user, + ) + await hass.async_block_till_done() + assert len(calls) == 1 # No new call + + hass.bus.async_fire( + "test_event", + {"attr_1": "milk", "another": "value", "attr_2": "beer"}, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_event_with_empty_data_and_context_config( hass, calls, context_with_user ): diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index b9696fffe06..831e20b78a1 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1,5 +1,6 @@ """The tests for numeric state automation.""" from datetime import timedelta +import logging from unittest.mock import patch import pytest @@ -9,7 +10,12 @@ import homeassistant.components.automation as automation from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, ) -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, +) from homeassistant.core import Context from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -240,7 +246,7 @@ async def test_if_not_below_fires_on_entity_change_to_equal(hass, calls, below): @pytest.mark.parametrize("below", (10, "input_number.value_10")) -async def test_if_fires_on_initial_entity_below(hass, calls, below): +async def test_if_not_fires_on_initial_entity_below(hass, calls, below): """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 9) await hass.async_block_till_done() @@ -260,14 +266,14 @@ async def test_if_fires_on_initial_entity_below(hass, calls, below): }, ) - # Fire on first update even if initial state was already below + # Do not fire on first update when initial state was already below hass.states.async_set("test.entity", 8) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 0 @pytest.mark.parametrize("above", (10, "input_number.value_10")) -async def test_if_fires_on_initial_entity_above(hass, calls, above): +async def test_if_not_fires_on_initial_entity_above(hass, calls, above): """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -287,10 +293,10 @@ async def test_if_fires_on_initial_entity_above(hass, calls, above): }, ) - # Fire on first update even if initial state was already above + # Do not fire on first update when initial state was already above hass.states.async_set("test.entity", 12) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 0 @pytest.mark.parametrize("above", (10, "input_number.value_10")) @@ -319,6 +325,74 @@ async def test_if_fires_on_entity_change_above(hass, calls, above): assert len(calls) == 1 +async def test_if_fires_on_entity_unavailable_at_startup(hass, calls): + """Test the firing with changed entity at startup.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "above": 10, + }, + "action": {"service": "test.automation"}, + } + }, + ) + # 11 is above 10 + hass.states.async_set("test.entity", 11) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_if_not_fires_on_entity_unavailable(hass, calls): + """Test the firing with entity changing to unavailable.""" + # set initial state + hass.states.async_set("test.entity", 9) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "above": 10, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # 11 is above 10 + hass.states.async_set("test.entity", 11) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Going to unavailable and back should not fire + hass.states.async_set("test.entity", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert len(calls) == 1 + hass.states.async_set("test.entity", 11) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Crossing threshold via unavailable should fire + hass.states.async_set("test.entity", 9) + await hass.async_block_till_done() + assert len(calls) == 1 + hass.states.async_set("test.entity", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert len(calls) == 1 + hass.states.async_set("test.entity", 11) + await hass.async_block_till_done() + assert len(calls) == 2 + + @pytest.mark.parametrize("above", (10, "input_number.value_10")) async def test_if_fires_on_entity_change_below_to_above(hass, calls, above): """Test the firing with changed entity.""" @@ -572,6 +646,34 @@ async def test_if_not_fires_if_entity_not_match(hass, calls, below): assert len(calls) == 0 +async def test_if_not_fires_and_warns_if_below_entity_unknown(hass, caplog, calls): + """Test if warns with unknown below entity.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "below": "input_number.unknown", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + caplog.clear() + caplog.set_level(logging.WARNING) + + hass.states.async_set("test.entity", 1) + await hass.async_block_till_done() + assert len(calls) == 0 + + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.WARNING + + @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below): """Test attributes change.""" @@ -1177,7 +1279,7 @@ async def test_wait_template_with_trigger(hass, calls, above): hass.states.async_set("test.entity", "8") await hass.async_block_till_done() assert len(calls) == 1 - assert "numeric_state - test.entity - 12" == calls[0].data["some"] + assert calls[0].data["some"] == "numeric_state - test.entity - 12" @pytest.mark.parametrize( @@ -1420,6 +1522,48 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below) assert len(calls) == 1 +async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls): + """Test for not firing on error with for template.""" + hass.states.async_set("test.entity", 0) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "above": 100, + "for": "00:00:05", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set("test.entity", 101) + await hass.async_block_till_done() + assert len(calls) == 0 + + caplog.clear() + caplog.set_level(logging.WARNING) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) + hass.states.async_set("test.entity", "unavailable") + await hass.async_block_till_done() + assert len(calls) == 0 + + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.WARNING + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) + hass.states.async_set("test.entity", 101) + await hass.async_block_till_done() + assert len(calls) == 0 + + @pytest.mark.parametrize( "above, below", ( @@ -1621,3 +1765,93 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert len(calls) == 1 + + +@pytest.mark.parametrize( + "above, below", + ((8, 12),), +) +async def test_variables_priority(hass, calls, above, below): + """Test an externally defined trigger variable is overridden.""" + hass.states.async_set("test.entity_1", 0) + hass.states.async_set("test.entity_2", 0) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"trigger": "illegal"}, + "trigger": { + "platform": "numeric_state", + "entity_id": ["test.entity_1", "test.entity_2"], + "above": above, + "below": below, + "for": '{{ 5 if trigger.entity_id == "test.entity_1"' + " else 10 }}", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.entity_id }} - {{ trigger.for }}" + }, + }, + } + }, + ) + await hass.async_block_till_done() + + utcnow = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow") as mock_utcnow: + mock_utcnow.return_value = utcnow + hass.states.async_set("test.entity_1", 9) + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", 15) + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + assert len(calls) == 0 + mock_utcnow.return_value += timedelta(seconds=3) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + + +@pytest.mark.parametrize("multiplier", (1, 5)) +async def test_template_variable(hass, calls, multiplier): + """Test template variable.""" + hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 11]}) + await hass.async_block_till_done() + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"multiplier": multiplier}, + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "value_template": "{{ state.attributes.test_attribute[2] * multiplier}}", + "below": 10, + }, + "action": {"service": "test.automation"}, + } + }, + ) + # 3 is below 10 + hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 3]}) + await hass.async_block_till_done() + if multiplier * 3 < 10: + assert len(calls) == 1 + else: + assert len(calls) == 0 diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index dd98dbc429c..2cf2081f018 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -984,6 +984,33 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_change_with_for_template_4(hass, calls): + """Test for firing on change with for template.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"seconds": 5}, + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "to": "world", + "for": {"seconds": "{{ seconds }}"}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set("test.entity", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_change_from_with_for(hass, calls): """Test for firing on change with from/for.""" assert await async_setup_component( @@ -1269,3 +1296,64 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( hass.states.async_set("test.entity", "bla", {"happening": True}) await hass.async_block_till_done() assert len(calls) == 1 + + +async def test_variables_priority(hass, calls): + """Test an externally defined trigger variable is overridden.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"trigger": "illegal"}, + "trigger": { + "platform": "state", + "entity_id": ["test.entity_1", "test.entity_2"], + "to": "world", + "for": '{{ 5 if trigger.entity_id == "test.entity_1"' + " else 10 }}", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.entity_id }} - {{ trigger.for }}" + }, + }, + } + }, + ) + await hass.async_block_till_done() + + utcnow = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: + mock_utcnow.return_value = utcnow + hass.states.async_set("test.entity_1", "world") + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", "hello") + await hass.async_block_till_done() + mock_utcnow.return_value += timedelta(seconds=1) + async_fire_time_changed(hass, mock_utcnow.return_value) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + mock_utcnow.return_value += timedelta(seconds=3) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + + mock_utcnow.return_value += timedelta(seconds=3) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + assert len(calls) == 1 + mock_utcnow.return_value += timedelta(seconds=5) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2 - 0:00:10" diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py index 20aa0e04c2b..6b1d87e3f54 100644 --- a/tests/components/homekit/common.py +++ b/tests/components/homekit/common.py @@ -1,17 +1,9 @@ """Collection of fixtures and functions for the HomeKit tests.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock EMPTY_8_6_JPEG = b"empty_8_6" -def patch_debounce(): - """Return patch for debounce method.""" - return patch( - "homeassistant.components.homekit.accessories.debounce", - lambda f: lambda *args, **kwargs: f(*args, **kwargs), - ) - - def mock_turbo_jpeg( first_width=None, second_width=None, first_height=None, second_height=None ): diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index ac51c4e6368..228b5f07837 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -5,7 +5,8 @@ from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED -from homeassistant.core import callback as ha_callback + +from tests.common import async_capture_events @pytest.fixture @@ -24,8 +25,4 @@ def hk_driver(loop): @pytest.fixture def events(hass): """Yield caught homekit_changed events.""" - events = [] - hass.bus.async_listen( - EVENT_HOMEKIT_CHANGED, ha_callback(lambda e: events.append(e)) - ) - yield events + return async_capture_events(hass, EVENT_HOMEKIT_CHANGED) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 886123062c4..afaa9ea0892 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -2,7 +2,6 @@ This includes tests for all mock object types. """ -from datetime import timedelta from unittest.mock import Mock, patch import pytest @@ -11,7 +10,6 @@ from homeassistant.components.homekit.accessories import ( HomeAccessory, HomeBridge, HomeDriver, - debounce, ) from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, @@ -45,41 +43,8 @@ from homeassistant.const import ( __version__, ) from homeassistant.helpers.event import TRACK_STATE_CHANGE_CALLBACKS -import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service - - -async def test_debounce(hass): - """Test add_timeout decorator function.""" - - def demo_func(*args): - nonlocal arguments, counter - counter += 1 - arguments = args - - arguments = None - counter = 0 - mock = Mock(hass=hass, debounce={}) - - debounce_demo = debounce(demo_func) - assert debounce_demo.__name__ == "demo_func" - now = dt_util.utcnow() - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.async_add_executor_job(debounce_demo, mock, "value") - async_fire_time_changed(hass, now + timedelta(seconds=3)) - await hass.async_block_till_done() - assert counter == 1 - assert len(arguments) == 2 - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.async_add_executor_job(debounce_demo, mock, "value") - await hass.async_add_executor_job(debounce_demo, mock, "value") - - async_fire_time_changed(hass, now + timedelta(seconds=3)) - await hass.async_block_till_done() - assert counter == 2 +from tests.common import async_mock_service async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): @@ -92,7 +57,7 @@ async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - await acc.run_handler() + await acc.run() assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS][entity_id]) == 1 acc.async_stop() assert entity_id not in hass.data[TRACK_STATE_CHANGE_CALLBACKS] @@ -156,7 +121,7 @@ async def test_home_accessory(hass, hk_driver): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -191,7 +156,7 @@ async def test_battery_service(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -247,7 +212,7 @@ async def test_battery_service(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -288,7 +253,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -333,7 +298,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -375,7 +340,7 @@ async def test_linked_battery_charging_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -387,7 +352,7 @@ async def test_linked_battery_charging_sensor(hass, hk_driver, caplog): "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: hass.states.async_set(linked_battery_charging_sensor, STATE_OFF, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -397,7 +362,7 @@ async def test_linked_battery_charging_sensor(hass, hk_driver, caplog): "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: hass.states.async_set(linked_battery_charging_sensor, STATE_ON, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -407,7 +372,7 @@ async def test_linked_battery_charging_sensor(hass, hk_driver, caplog): "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: hass.states.async_remove(linked_battery_charging_sensor) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc._char_charging.value == 1 @@ -440,7 +405,7 @@ async def test_linked_battery_sensor_and_linked_battery_charging_sensor( with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -484,7 +449,7 @@ async def test_missing_linked_battery_charging_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - await acc.run_handler() + await acc.run() await hass.async_block_till_done() # Make sure we don't throw if the entity_id @@ -493,7 +458,7 @@ async def test_missing_linked_battery_charging_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - await acc.run_handler() + await acc.run() await hass.async_block_till_done() @@ -517,7 +482,7 @@ async def test_missing_linked_battery_sensor(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -531,7 +496,7 @@ async def test_missing_linked_battery_sensor(hass, hk_driver, caplog): "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: hass.states.async_remove(entity_id) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert not acc.linked_battery_sensor @@ -552,7 +517,7 @@ async def test_battery_appears_after_startup(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ) as mock_async_update_state: - await acc.run_handler() + await acc.run() await hass.async_block_till_done() state = hass.states.get(entity_id) mock_async_update_state.assert_called_with(state) @@ -586,7 +551,7 @@ async def test_call_service(hass, hk_driver, events): test_service = "open_cover" test_value = "value" - await acc.async_call_service( + acc.async_call_service( test_domain, test_service, {ATTR_ENTITY_ID: entity_id}, test_value ) await hass.async_block_till_done() @@ -638,13 +603,19 @@ def test_home_driver(): with patch("pyhap.accessory_driver.AccessoryDriver.__init__") as mock_driver: driver = HomeDriver( - "hass", "entry_id", "name", address=ip_address, port=port, persist_file=path + "hass", + "entry_id", + "name", + "title", + address=ip_address, + port=port, + persist_file=path, ) mock_driver.assert_called_with(address=ip_address, port=port, persist_file=path) - driver.state = Mock(pincode=pin) + driver.state = Mock(pincode=pin, paired=False) xhm_uri_mock = Mock(return_value="X-HM://0") - driver.accessory = Mock(xhm_uri=xhm_uri_mock) + driver.accessory = Mock(display_name="any", xhm_uri=xhm_uri_mock) # pair with patch("pyhap.accessory_driver.AccessoryDriver.pair") as mock_pair, patch( @@ -662,4 +633,4 @@ def test_home_driver(): driver.unpair("client_uuid") mock_unpair.assert_called_with("client_uuid") - mock_show_msg.assert_called_with("hass", "entry_id", "name", pin, "X-HM://0") + mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 4438404af2e..3d94672cd8a 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT @@ -32,35 +32,168 @@ def _mock_config_entry_with_options_populated(): ) -async def test_user_form(hass): - """Test we can setup a new instance.""" +async def test_setup_in_bridge_mode(hass): + """Test we can setup a new instance in bridge mode.""" 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"] == "form" - assert result["errors"] == {} - - with patch( - "homeassistant.components.homekit.config_flow.find_next_available_port", - return_value=12345, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"include_domains": ["light"]}, - ) + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"include_domains": ["light"]}, + ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "pairing" with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), patch( "homeassistant.components.homekit.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.homekit.async_setup_entry", return_value=True, ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + bridge_name = (result3["title"].split(":"))[0] + assert bridge_name == SHORT_BRIDGE_NAME + assert result3["data"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["light"], + "include_entities": [], + }, + "exclude_accessory_mode": True, + "mode": "bridge", + "name": bridge_name, + "port": 12345, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_setup_in_bridge_mode_name_taken(hass): + """Test we can setup a new instance in bridge mode when the name is taken.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: SHORT_BRIDGE_NAME, CONF_PORT: 8000}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"include_domains": ["light"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + + with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), patch( + "homeassistant.components.homekit.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.homekit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] != SHORT_BRIDGE_NAME + assert result3["title"].startswith(SHORT_BRIDGE_NAME) + bridge_name = (result3["title"].split(":"))[0] + assert result3["data"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["light"], + "include_entities": [], + }, + "exclude_accessory_mode": True, + "mode": "bridge", + "name": bridge_name, + "port": 12345, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_setup_creates_entries_for_accessory_mode_devices(hass): + """Test we can setup a new instance and we create entries for accessory mode devices.""" + hass.states.async_set("camera.one", "on") + hass.states.async_set("camera.existing", "on") + hass.states.async_set("media_player.two", "on", {"device_class": "tv"}) + + bridge_mode_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "bridge", CONF_PORT: 8001}, + options={ + "mode": "bridge", + "filter": { + "include_entities": ["camera.existing"], + }, + }, + ) + bridge_mode_entry.add_to_hass(hass) + accessory_mode_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "accessory", CONF_PORT: 8000}, + options={ + "mode": "accessory", + "filter": { + "include_entities": ["camera.existing"], + }, + }, + ) + accessory_mode_entry.add_to_hass(hass) + + 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"] == "form" + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"include_domains": ["camera", "media_player", "light"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + + with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), patch( + "homeassistant.components.homekit.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.homekit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {}, ) await hass.async_block_till_done() @@ -72,14 +205,24 @@ async def test_user_form(hass): "filter": { "exclude_domains": [], "exclude_entities": [], - "include_domains": ["light"], + "include_domains": ["media_player", "light"], "include_entities": [], }, + "exclude_accessory_mode": True, + "mode": "bridge", "name": bridge_name, "port": 12345, } assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + # + # Existing accessory mode entries should get setup but not duplicated + # + # 1 - existing accessory for camera.existing + # 2 - existing bridge for camera.one + # 3 - new bridge + # 4 - camera.one in accessory mode + # 5 - media_player.two in accessory mode + assert len(mock_setup_entry.mock_calls) == 5 async def test_import(hass): @@ -343,10 +486,11 @@ async def test_options_flow_exclude_mode_with_cameras(hass): result3 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={"camera_copy": []}, + user_input={"camera_copy": ["camera.native_h264"]}, ) assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { "auto_start": True, "mode": "bridge", @@ -356,7 +500,7 @@ async def test_options_flow_exclude_mode_with_cameras(hass): "include_domains": ["fan", "vacuum", "climate", "camera"], "include_entities": [], }, - "entity_config": {"camera.native_h264": {}}, + "entity_config": {"camera.native_h264": {"video_codec": "copy"}}, } @@ -424,6 +568,18 @@ async def test_options_flow_include_mode_with_cameras(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": ["fan", "vacuum", "climate", "camera"], + "mode": "bridge", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "domains") == [ + "fan", + "vacuum", + "climate", + "camera", + ] + assert _get_schema_default(schema, "mode") == "bridge" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -432,6 +588,16 @@ async def test_options_flow_include_mode_with_cameras(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "include_exclude" + assert result["data_schema"]({}) == { + "entities": ["camera.native_h264", "camera.transcode_h264"], + "include_exclude_mode": "include", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "entities") == [ + "camera.native_h264", + "camera.transcode_h264", + ] + assert _get_schema_default(schema, "include_exclude_mode") == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], @@ -442,6 +608,9 @@ async def test_options_flow_include_mode_with_cameras(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "cameras" + assert result2["data_schema"]({}) == {"camera_copy": ["camera.native_h264"]} + schema = result2["data_schema"].schema + assert _get_schema_default(schema, "camera_copy") == ["camera.native_h264"] result3 = await hass.config_entries.options.async_configure( result2["flow_id"], @@ -451,14 +620,14 @@ async def test_options_flow_include_mode_with_cameras(hass): assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "auto_start": True, - "mode": "bridge", + "entity_config": {"camera.native_h264": {}}, "filter": { "exclude_domains": [], "exclude_entities": ["climate.old", "camera.excluded"], "include_domains": ["fan", "vacuum", "climate", "camera"], "include_entities": [], }, - "entity_config": {"camera.native_h264": {}}, + "mode": "bridge", } @@ -518,20 +687,32 @@ async def test_options_flow_include_mode_basic_accessory(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": [ + "fan", + "humidifier", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "mode": "bridge", + } - result = await hass.config_entries.options.async_configure( + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"domains": ["media_player"], "mode": "accessory"}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "include_exclude" + assert _get_schema_default(result2["data_schema"].schema, "entities") == [] - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={"entities": "media_player.tv"}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "auto_start": True, "mode": "accessory", @@ -542,3 +723,110 @@ async def test_options_flow_include_mode_basic_accessory(hass): "include_entities": ["media_player.tv"], }, } + + +async def test_converting_bridge_to_accessory_mode(hass, hk_driver): + """Test we can convert a bridge to accessory mode.""" + 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"] == "form" + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"include_domains": ["light"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + + # We need to actually setup the config entry or the data + # will not get migrated to options + with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), patch( + "homeassistant.components.homekit.HomeKit.async_start", + return_value=True, + ) as mock_async_start: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"][:11] == "HASS Bridge" + bridge_name = (result3["title"].split(":"))[0] + assert result3["data"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["light"], + "include_entities": [], + }, + "exclude_accessory_mode": True, + "mode": "bridge", + "name": bridge_name, + "port": 12345, + } + assert len(mock_async_start.mock_calls) == 1 + + config_entry = result3["result"] + + hass.states.async_set("camera.tv", "off") + hass.states.async_set("camera.sonos", "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" + schema = result["data_schema"].schema + assert _get_schema_default(schema, "mode") == "bridge" + assert _get_schema_default(schema, "domains") == ["light"] + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["camera"], "mode": "accessory"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": "camera.tv"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_copy": ["camera.tv"]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "entity_config": {"camera.tv": {"video_codec": "copy"}}, + "mode": "accessory", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["camera.tv"], + }, + } + + +def _get_schema_default(schema, key_name): + """Iterate schema to find a key.""" + for schema_key in schema: + if schema_key == key_name: + return schema_key.default() + raise KeyError(f"{key_name} not found in schema") diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index c6f897c32a2..9ce3e96f06f 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -27,20 +27,15 @@ from homeassistant.components.homekit.const import ( BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CONF_AUTO_START, - CONF_ENTRY_INDEX, DEFAULT_PORT, DOMAIN, HOMEKIT, - HOMEKIT_FILE, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, ) -from homeassistant.components.homekit.util import ( - get_aid_storage_fullpath_for_entry_id, - get_persist_fullpath_for_entry_id, -) +from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -51,7 +46,7 @@ from homeassistant.const import ( CONF_PORT, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, - EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, SERVICE_RELOAD, @@ -60,14 +55,12 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.helpers import device_registry from homeassistant.helpers.entityfilter import generate_filter -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.setup import async_setup_component from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration from tests.common import MockConfigEntry, mock_device_registry, mock_registry -from tests.components.homekit.common import patch_debounce IP_ADDRESS = "127.0.0.1" @@ -89,12 +82,20 @@ def entity_reg_fixture(hass): return mock_registry(hass) -@pytest.fixture(name="debounce_patcher", scope="module") -def debounce_patcher_fixture(): - """Patch debounce method.""" - patcher = patch_debounce() - yield patcher.start() - patcher.stop() +def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): + return HomeKit( + hass=hass, + name=BRIDGE_NAME, + port=DEFAULT_PORT, + ip_address=None, + entity_filter=entity_filter or generate_filter([], [], [], []), + exclude_accessory_mode=False, + entity_config={}, + homekit_mode=homekit_mode, + advertise_ip=None, + entry_id=entry.entry_id, + entry_title=entry.title, + ) async def test_setup_min(hass, mock_zeroconf): @@ -118,16 +119,18 @@ async def test_setup_min(hass, mock_zeroconf): DEFAULT_PORT, None, ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True # Test auto start enabled mock_homekit.reset_mock() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() mock_homekit().async_start.assert_called() @@ -154,17 +157,19 @@ async def test_setup_auto_start_disabled(hass, mock_zeroconf): 11111, "172.0.0.0", ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True # Test auto_start disabled homekit.reset_mock() homekit.async_start.reset_mock() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert homekit.async_start.called is False @@ -199,18 +204,20 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, None, + True, {}, {}, HOMEKIT_MODE_BRIDGE, advertise_ip=None, entry_id=entry.entry_id, + entry_title=entry.title, ) hass.states.async_set("light.demo", "on") hass.states.async_set("light.demo2", "on") zeroconf_mock = MagicMock() with patch( - f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver + f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver ) as mock_driver, patch("homeassistant.util.get_local_ip") as mock_ip: mock_ip.return_value = IP_ADDRESS await hass.async_add_executor_job(homekit.setup, zeroconf_mock) @@ -220,6 +227,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address=IP_ADDRESS, port=DEFAULT_PORT, @@ -245,23 +253,24 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, "172.0.0.0", + True, {}, {}, HOMEKIT_MODE_BRIDGE, None, entry_id=entry.entry_id, + entry_title=entry.title, ) mock_zeroconf = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) - with patch( - f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver - ) as mock_driver: + with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: await hass.async_add_executor_job(homekit.setup, mock_zeroconf) mock_driver.assert_called_with( hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address="172.0.0.0", port=DEFAULT_PORT, @@ -283,23 +292,24 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, "0.0.0.0", + True, {}, {}, HOMEKIT_MODE_BRIDGE, "192.168.1.100", entry_id=entry.entry_id, + entry_title=entry.title, ) zeroconf_instance = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) - with patch( - f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver - ) as mock_driver: + with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: await hass.async_add_executor_job(homekit.setup, zeroconf_instance) mock_driver.assert_called_with( hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address="0.0.0.0", port=DEFAULT_PORT, @@ -311,40 +321,40 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): async def test_homekit_add_accessory(hass, mock_zeroconf): """Add accessory if config exists and get_acc returns an accessory.""" - entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + entry.add_to_hass(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() homekit.bridge.accessories = range(10) + homekit.async_start = AsyncMock() + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_acc = Mock(category="any") - await async_init_integration(hass) - with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc: mock_get_acc.side_effect = [None, mock_acc, None] - homekit.add_bridge_accessory(State("light.demo", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1403373688, {}) + state = State("light.demo", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1403373688, {}) assert not mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("demo.test", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 600325356, {}) + state = State("demo.test", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 600325356, {}) assert mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("demo.test_2", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1467253281, {}) - mock_bridge.add_accessory.assert_called_with(mock_acc) + state = State("demo.test_2", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {}) + assert mock_bridge.add_accessory.called @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) @@ -352,37 +362,30 @@ async def test_homekit_warn_add_accessory_bridge( hass, acc_category, mock_zeroconf, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" - entry = await async_init_integration(hass) - - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + entry.add_to_hass(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() homekit.bridge.accessories = range(10) + homekit.async_start = AsyncMock() + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_camera_acc = Mock(category=acc_category) - await async_init_integration(hass) - with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc: mock_get_acc.side_effect = [None, mock_camera_acc, None] - homekit.add_bridge_accessory(State("light.demo", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1403373688, {}) + state = State("camera.test", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {}) assert not mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("camera.test", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1508819236, {}) - assert mock_bridge.add_accessory.called - assert "accessory mode" in caplog.text @@ -390,17 +393,8 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): """Remove accessory from bridge.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() mock_bridge.accessories = {"light.demo": "acc"} @@ -415,17 +409,8 @@ async def test_homekit_entity_filter(hass, mock_zeroconf): entry = await async_init_integration(hass) entity_filter = generate_filter(["cover"], ["demo.test"], [], []) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -451,17 +436,8 @@ async def test_homekit_entity_glob_filter(hass, mock_zeroconf): entity_filter = generate_filter( ["cover"], ["demo.test"], [], [], ["*.included_*"], ["*.excluded_*"] ) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -485,22 +461,13 @@ async def test_homekit_entity_glob_filter(hass, mock_zeroconf): mock_get_acc.reset_mock() -async def test_homekit_start(hass, hk_driver, device_reg, debounce_patcher): +async def test_homekit_start(hass, hk_driver, device_reg): """Test HomeKit start method.""" entry = await async_init_integration(hass) pin = b"123-45-678" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -532,7 +499,9 @@ async def test_homekit_start(hass, hk_driver, device_reg, debounce_patcher): await hass.async_block_till_done() mock_add_acc.assert_any_call(state) - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -571,11 +540,10 @@ async def test_homekit_start(hass, hk_driver, device_reg, debounce_patcher): assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections assert len(device_reg.devices) == 1 + assert homekit.driver.state.config_version == 2 -async def test_homekit_start_with_a_broken_accessory( - hass, hk_driver, debounce_patcher, mock_zeroconf -): +async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroconf): """Test HomeKit start method.""" pin = b"123-45-678" entry = MockConfigEntry( @@ -584,17 +552,7 @@ async def test_homekit_start_with_a_broken_accessory( entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) await async_init_entry(hass, entry) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) homekit.bridge = Mock() homekit.bridge.accessories = [] @@ -614,7 +572,9 @@ async def test_homekit_start_with_a_broken_accessory( await homekit.async_start() await hass.async_block_till_done() - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -629,18 +589,8 @@ async def test_homekit_start_with_a_broken_accessory( async def test_homekit_stop(hass): """Test HomeKit stop method.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = Mock() homekit.driver.async_stop = AsyncMock() homekit.bridge = Mock() @@ -670,17 +620,8 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) entity_id = "light.demo" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {entity_id: {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -718,17 +659,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) def _mock_bridge(*_): mock_bridge = HomeBridge(hass, hk_driver, "mock_bridge") @@ -746,7 +677,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory" ), patch(f"{PATH_HOMEKIT}.show_setup_message"), patch( - f"{PATH_HOMEKIT}.accessories.HomeBridge", _mock_bridge + f"{PATH_HOMEKIT}.HomeBridge", _mock_bridge ): await homekit.async_start() await hass.async_block_till_done() @@ -754,22 +685,13 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco async def test_homekit_finds_linked_batteries( - hass, hk_driver, debounce_patcher, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -813,9 +735,6 @@ async def test_homekit_finds_linked_batteries( ) hass.states.async_set(light.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -840,22 +759,12 @@ async def test_homekit_finds_linked_batteries( async def test_homekit_async_get_integration_fails( - hass, hk_driver, debounce_patcher, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_zeroconf ): """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -898,9 +807,6 @@ async def test_homekit_async_get_integration_fails( ) hass.states.async_set(light.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -924,69 +830,6 @@ async def test_homekit_async_get_integration_fails( ) -async def test_setup_imported(hass, mock_zeroconf): - """Test async_setup with imported config options.""" - legacy_persist_file_path = hass.config.path(HOMEKIT_FILE) - legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids") - legacy_homekit_state_contents = {"homekit.state": 1} - legacy_homekit_aids_contents = {"homekit.aids": 1} - await hass.async_add_executor_job( - _write_data, legacy_persist_file_path, legacy_homekit_state_contents - ) - await hass.async_add_executor_job( - _write_data, legacy_aid_storage_path, legacy_homekit_aids_contents - ) - - entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_IMPORT, - data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT, CONF_ENTRY_INDEX: 0}, - options={}, - ) - entry.add_to_hass(hass) - - with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: - mock_homekit.return_value = homekit = Mock() - type(homekit).async_start = AsyncMock() - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - mock_homekit.assert_any_call( - hass, - BRIDGE_NAME, - DEFAULT_PORT, - None, - ANY, - {}, - HOMEKIT_MODE_BRIDGE, - None, - entry.entry_id, - ) - assert mock_homekit().setup.called is True - - # Test auto start enabled - mock_homekit.reset_mock() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - mock_homekit().async_start.assert_called() - - migrated_persist_file_path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) - assert ( - await hass.async_add_executor_job( - json_util.load_json, migrated_persist_file_path - ) - == legacy_homekit_state_contents - ) - os.unlink(migrated_persist_file_path) - migrated_aid_file_path = get_aid_storage_fullpath_for_entry_id(hass, entry.entry_id) - assert ( - await hass.async_add_executor_job(json_util.load_json, migrated_aid_file_path) - == legacy_homekit_aids_contents - ) - os.unlink(migrated_aid_file_path) - - async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): """Test async_setup with imported config.""" entry = MockConfigEntry( @@ -1011,16 +854,18 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): 12345, None, ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True # Test auto start enabled mock_homekit.reset_mock() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() mock_homekit().async_start.assert_called() @@ -1035,10 +880,7 @@ async def test_raise_config_entry_not_ready(hass, mock_zeroconf): ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.homekit.port_is_available", - return_value=False, - ): + with patch(f"{PATH_HOMEKIT}.HomeKit.setup", side_effect=OSError): assert not await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -1072,22 +914,12 @@ def _write_data(path: str, data: Dict) -> None: async def test_homekit_ignored_missing_devices( - hass, hk_driver, debounce_patcher, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_zeroconf ): """Test HomeKit handles a device in the entity registry but missing from the device registry.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -1128,9 +960,6 @@ async def test_homekit_ignored_missing_devices( hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1153,22 +982,13 @@ async def test_homekit_ignored_missing_devices( async def test_homekit_finds_linked_motion_sensors( - hass, hk_driver, debounce_patcher, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"camera.camera_demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -1202,9 +1022,6 @@ async def test_homekit_finds_linked_motion_sensors( ) hass.states.async_set(camera.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1228,22 +1045,13 @@ async def test_homekit_finds_linked_motion_sensors( async def test_homekit_finds_linked_humidity_sensors( - hass, hk_driver, debounce_patcher, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"humidifier.humidifier": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver homekit._filter = Mock(return_value=True) homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") @@ -1279,9 +1087,6 @@ async def test_homekit_finds_linked_humidity_sensors( ) hass.states.async_set(humidifier.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1328,10 +1133,12 @@ async def test_reload(hass, mock_zeroconf): 12345, None, ANY, + False, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True yaml_path = os.path.join( @@ -1364,10 +1171,12 @@ async def test_reload(hass, mock_zeroconf): 45678, None, ANY, + False, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit2().setup.called is True @@ -1376,24 +1185,14 @@ 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, device_reg, debounce_patcher -): +async def test_homekit_start_in_accessory_mode(hass, hk_driver, device_reg): """Test HomeKit start method in accessory mode.""" entry = await async_init_integration(hass) pin = b"123-45-678" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_ACCESSORY, - advertise_ip=None, - entry_id=entry.entry_id, - ) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -1412,6 +1211,8 @@ async def test_homekit_start_in_accessory_mode( await hass.async_block_till_done() mock_add_acc.assert_not_called() - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 804e03a4e6c..ba08ea3caaf 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -45,35 +45,35 @@ PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647 async def _async_start_streaming(hass, acc): """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() async def _async_setup_endpoints(hass, acc): """Set camera endpoints.""" acc.set_endpoints(MOCK_END_POINTS_TLV) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() async def _async_reconfigure_stream(hass, acc, session_info, stream_config): """Reconfigure the stream.""" await acc.reconfigure_stream(session_info, stream_config) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() async def _async_stop_all_streams(hass, acc): """Stop all camera streams.""" await acc.stop() - await acc.run_handler() + await acc.run() await hass.async_block_till_done() async def _async_stop_stream(hass, acc, session_info): """Stop a camera stream.""" await acc.stop_stream(session_info) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() @@ -99,6 +99,7 @@ def _get_exits_after_startup_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) + ffmpeg.get_reader = AsyncMock() return ffmpeg @@ -108,6 +109,7 @@ def _get_working_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) + ffmpeg.get_reader = AsyncMock() return ffmpeg @@ -118,6 +120,7 @@ def _get_failing_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=False) ffmpeg.close = AsyncMock(side_effect=OSError) ffmpeg.kill = AsyncMock(side_effect=OSError) + ffmpeg.get_reader = AsyncMock() return ffmpeg @@ -153,7 +156,7 @@ async def test_camera_stream_source_configured(hass, run_driver, events): bridge.add_accessory(acc) bridge.add_accessory(not_camera_acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -189,6 +192,8 @@ async def test_camera_stream_source_configured(hass, run_driver, events): input_source="-i /dev/null", output=expected_output.format(**session_info), stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, ) await _async_setup_endpoints(hass, acc) @@ -212,22 +217,23 @@ async def test_camera_stream_source_configured(hass, run_driver, events): ) with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() - assert await hass.async_add_executor_job( - acc.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200} + assert await acc.async_get_snapshot( + {"aid": 2, "image-width": 300, "image-height": 200} ) - # Verify the bridge only forwards get_snapshot for + # Verify the bridge only forwards async_get_snapshot for # cameras and valid accessory ids - assert await hass.async_add_executor_job( - bridge.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200} + assert await bridge.async_get_snapshot( + {"aid": 2, "image-width": 300, "image-height": 200} ) with pytest.raises(ValueError): - assert await hass.async_add_executor_job( - bridge.get_snapshot, {"aid": 3, "image-width": 300, "image-height": 200} + assert await bridge.async_get_snapshot( + {"aid": 3, "image-width": 300, "image-height": 200} ) + with pytest.raises(ValueError): - assert await hass.async_add_executor_job( - bridge.get_snapshot, {"aid": 4, "image-width": 300, "image-height": 200} + assert await bridge.async_get_snapshot( + {"aid": 4, "image-width": 300, "image-height": 200} ) @@ -265,7 +271,7 @@ async def test_camera_stream_source_configured_with_failing_ffmpeg( bridge.add_accessory(acc) bridge.add_accessory(not_camera_acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -305,7 +311,7 @@ async def test_camera_stream_source_found(hass, run_driver, events): 2, {}, ) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -355,7 +361,7 @@ async def test_camera_stream_source_fails(hass, run_driver, events): 2, {}, ) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -390,7 +396,7 @@ async def test_camera_with_no_stream(hass, run_driver, events): 2, {}, ) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -400,8 +406,8 @@ async def test_camera_with_no_stream(hass, run_driver, events): await _async_stop_all_streams(hass, acc) with pytest.raises(HomeAssistantError): - await hass.async_add_executor_job( - acc.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200} + assert await acc.async_get_snapshot( + {"aid": 2, "image-width": 300, "image-height": 200} ) @@ -433,7 +439,7 @@ async def test_camera_stream_source_configured_and_copy_codec(hass, run_driver, bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -471,6 +477,8 @@ async def test_camera_stream_source_configured_and_copy_codec(hass, run_driver, input_source="-i /dev/null", output=expected_output.format(**session_info), stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, ) @@ -502,7 +510,7 @@ async def test_camera_streaming_fails_after_starting_ffmpeg(hass, run_driver, ev bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -541,6 +549,8 @@ async def test_camera_streaming_fails_after_starting_ffmpeg(hass, run_driver, ev input_source="-i /dev/null", output=expected_output.format(**session_info), stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, ) @@ -578,7 +588,7 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -607,7 +617,7 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): # motion sensor is removed hass.states.async_remove(motion_entity_id) await hass.async_block_till_done() - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert char.value is True @@ -634,7 +644,7 @@ async def test_camera_with_a_missing_linked_motion_sensor(hass, run_driver, even bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -676,7 +686,7 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera @@ -715,7 +725,7 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): # doorbell sensor is removed hass.states.async_remove(doorbell_entity_id) await hass.async_block_till_done() - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert char.value == 0 assert char2.value == 0 @@ -743,7 +753,7 @@ async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, ev bridge = HomeBridge("hass", run_driver, "Test Bridge") bridge.add_accessory(acc) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 17 # Camera diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 48b20e8e0b8..1c0de6c3af2 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -1,7 +1,4 @@ """Test different accessory types: Covers.""" -from collections import namedtuple - -import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -22,6 +19,12 @@ from homeassistant.components.homekit.const import ( HK_DOOR_OPEN, HK_DOOR_OPENING, ) +from homeassistant.components.homekit.type_covers import ( + GarageDoorOpener, + Window, + WindowCovering, + WindowCoveringBasic, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -40,38 +43,16 @@ from homeassistant.core import CoreState from homeassistant.helpers import entity_registry from tests.common import async_mock_service -from tests.components.homekit.common import patch_debounce -@pytest.fixture(scope="module") -def cls(): - """Patch debounce decorator during import of type_covers.""" - patcher = patch_debounce() - patcher.start() - _import = __import__( - "homeassistant.components.homekit.type_covers", - fromlist=["GarageDoorOpener", "WindowCovering", "WindowCoveringBasic"], - ) - patcher_tuple = namedtuple( - "Cls", ["window", "windowcovering", "windowcovering_basic", "garage"] - ) - yield patcher_tuple( - window=_import.Window, - windowcovering=_import.WindowCovering, - windowcovering_basic=_import.WindowCoveringBasic, - garage=_import.GarageDoorOpener, - ) - patcher.stop() - - -async def test_garage_door_open_close(hass, hk_driver, cls, events): +async def test_garage_door_open_close(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.garage_door" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = cls.garage(hass, hk_driver, "Garage Door", entity_id, 2, None) - await acc.run_handler() + acc = GarageDoorOpener(hass, hk_driver, "Garage Door", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -148,14 +129,14 @@ async def test_garage_door_open_close(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None -async def test_windowcovering_set_cover_position(hass, hk_driver, cls, events): +async def test_windowcovering_set_cover_position(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) - await acc.run_handler() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -218,14 +199,14 @@ async def test_windowcovering_set_cover_position(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == 75 -async def test_window_instantiate(hass, hk_driver, cls, events): +async def test_window_instantiate(hass, hk_driver, events): """Test if Window accessory is instantiated correctly.""" entity_id = "cover.window" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = cls.window(hass, hk_driver, "Window", entity_id, 2, None) - await acc.run_handler() + acc = Window(hass, hk_driver, "Window", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -235,7 +216,7 @@ async def test_window_instantiate(hass, hk_driver, cls, events): assert acc.char_target_position.value == 0 -async def test_windowcovering_cover_set_tilt(hass, hk_driver, cls, events): +async def test_windowcovering_cover_set_tilt(hass, hk_driver, events): """Test if accessory and HA update slat tilt accordingly.""" entity_id = "cover.window" @@ -243,8 +224,8 @@ async def test_windowcovering_cover_set_tilt(hass, hk_driver, cls, events): entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION} ) await hass.async_block_till_done() - acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) - await acc.run_handler() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -302,13 +283,13 @@ async def test_windowcovering_cover_set_tilt(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == 75 -async def test_windowcovering_open_close(hass, hk_driver, cls, events): +async def test_windowcovering_open_close(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: 0}) - acc = cls.windowcovering_basic(hass, hk_driver, "Cover", entity_id, 2, None) - await acc.run_handler() + acc = WindowCoveringBasic(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -383,15 +364,15 @@ async def test_windowcovering_open_close(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None -async def test_windowcovering_open_close_stop(hass, hk_driver, cls, events): +async def test_windowcovering_open_close_stop(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" hass.states.async_set( entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP} ) - acc = cls.windowcovering_basic(hass, hk_driver, "Cover", entity_id, 2, None) - await acc.run_handler() + acc = WindowCoveringBasic(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() # Set from HomeKit @@ -431,7 +412,7 @@ async def test_windowcovering_open_close_stop(hass, hk_driver, cls, events): async def test_windowcovering_open_close_with_position_and_stop( - hass, hk_driver, cls, events + hass, hk_driver, events ): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.stop_window" @@ -441,8 +422,8 @@ async def test_windowcovering_open_close_with_position_and_stop( STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP | SUPPORT_SET_POSITION}, ) - acc = cls.windowcovering(hass, hk_driver, "Cover", entity_id, 2, None) - await acc.run_handler() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() await hass.async_block_till_done() # Set from HomeKit @@ -461,7 +442,7 @@ async def test_windowcovering_open_close_with_position_and_stop( assert events[-1].data[ATTR_VALUE] is None -async def test_windowcovering_basic_restore(hass, hk_driver, cls, events): +async def test_windowcovering_basic_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -486,22 +467,20 @@ async def test_windowcovering_basic_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.windowcovering_basic(hass, hk_driver, "Cover", "cover.simple", 2, None) + acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.simple", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = cls.windowcovering_basic( - hass, hk_driver, "Cover", "cover.all_info_set", 2, None - ) + acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None -async def test_windowcovering_restore(hass, hk_driver, cls, events): +async def test_windowcovering_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -526,20 +505,20 @@ async def test_windowcovering_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.windowcovering(hass, hk_driver, "Cover", "cover.simple", 2, None) + acc = WindowCovering(hass, hk_driver, "Cover", "cover.simple", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = cls.windowcovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None assert acc.char_position_state is not None -async def test_garage_door_with_linked_obstruction_sensor(hass, hk_driver, cls, events): +async def test_garage_door_with_linked_obstruction_sensor(hass, hk_driver, events): """Test if accessory and HA are updated accordingly with a linked obstruction sensor.""" linked_obstruction_sensor_entity_id = "binary_sensor.obstruction" entity_id = "cover.garage_door" @@ -547,7 +526,7 @@ async def test_garage_door_with_linked_obstruction_sensor(hass, hk_driver, cls, hass.states.async_set(linked_obstruction_sensor_entity_id, STATE_OFF) hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = cls.garage( + acc = GarageDoorOpener( hass, hk_driver, "Garage Door", @@ -555,7 +534,7 @@ async def test_garage_door_with_linked_obstruction_sensor(hass, hk_driver, cls, 2, {CONF_LINKED_OBSTRUCTION_SENSOR: linked_obstruction_sensor_entity_id}, ) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index bc1bac11844..ba660f2f12d 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -1,27 +1,24 @@ """Test different accessory types: Fans.""" -from collections import namedtuple -from unittest.mock import Mock from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE -import pytest from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, - ATTR_SPEED, - ATTR_SPEED_LIST, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, ) -from homeassistant.components.homekit.const import ATTR_VALUE -from homeassistant.components.homekit.util import HomeKitSpeedMapping +from homeassistant.components.homekit.const import ATTR_VALUE, PROP_MIN_STEP +from homeassistant.components.homekit.type_fans import Fan from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -34,27 +31,15 @@ from homeassistant.core import CoreState from homeassistant.helpers import entity_registry from tests.common import async_mock_service -from tests.components.homekit.common import patch_debounce -@pytest.fixture(scope="module") -def cls(): - """Patch debounce decorator during import of type_fans.""" - patcher = patch_debounce() - patcher.start() - _import = __import__("homeassistant.components.homekit.type_fans", fromlist=["Fan"]) - patcher_tuple = namedtuple("Cls", ["fan"]) - yield patcher_tuple(fan=_import.Fan) - patcher.stop() - - -async def test_fan_basic(hass, hk_driver, cls, events): +async def test_fan_basic(hass, hk_driver, events): """Test fan with char state.""" entity_id = "fan.demo" hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", entity_id, 1, None) + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.aid == 1 @@ -64,7 +49,7 @@ async def test_fan_basic(hass, hk_driver, cls, events): # If there are no speed_list values, then HomeKit speed is unsupported assert acc.char_speed is None - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_active.value == 1 @@ -126,7 +111,7 @@ async def test_fan_basic(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is None -async def test_fan_direction(hass, hk_driver, cls, events): +async def test_fan_direction(hass, hk_driver, events): """Test fan with direction.""" entity_id = "fan.demo" @@ -136,12 +121,12 @@ async def test_fan_direction(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION, ATTR_DIRECTION: DIRECTION_FORWARD}, ) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", entity_id, 1, None) + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.char_direction.value == 0 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_direction.value == 0 @@ -194,7 +179,7 @@ async def test_fan_direction(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == DIRECTION_REVERSE -async def test_fan_oscillate(hass, hk_driver, cls, events): +async def test_fan_oscillate(hass, hk_driver, events): """Test fan with oscillate.""" entity_id = "fan.demo" @@ -204,12 +189,12 @@ async def test_fan_oscillate(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False}, ) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", entity_id, 1, None) + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.char_swing.value == 0 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_swing.value == 0 @@ -263,45 +248,37 @@ async def test_fan_oscillate(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] is True -async def test_fan_speed(hass, hk_driver, cls, events): +async def test_fan_speed(hass, hk_driver, events): """Test fan with speed.""" entity_id = "fan.demo" - speed_list = [SPEED_OFF, SPEED_LOW, SPEED_HIGH] hass.states.async_set( entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED, - ATTR_SPEED: SPEED_OFF, - ATTR_SPEED_LIST: speed_list, + ATTR_PERCENTAGE: 0, + ATTR_PERCENTAGE_STEP: 25, }, ) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", entity_id, 1, None) + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) hk_driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # speed to 100 when turning on a fan on a freshly booted up server. assert acc.char_speed.value != 0 + assert acc.char_speed.properties[PROP_MIN_STEP] == 25 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() - assert ( - acc.speed_mapping.speed_ranges == HomeKitSpeedMapping(speed_list).speed_ranges - ) - - acc.speed_mapping.speed_to_homekit = Mock(return_value=42) - acc.speed_mapping.speed_to_states = Mock(return_value="ludicrous") - - hass.states.async_set(entity_id, STATE_ON, {ATTR_SPEED: SPEED_HIGH}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_PERCENTAGE: 100}) await hass.async_block_till_done() - acc.speed_mapping.speed_to_homekit.assert_called_with(SPEED_HIGH) - assert acc.char_speed.value == 42 + assert acc.char_speed.value == 100 # Set from HomeKit - call_set_speed = async_mock_service(hass, DOMAIN, "set_speed") + call_set_percentage = async_mock_service(hass, DOMAIN, "set_percentage") char_speed_iid = acc.char_speed.to_HAP()[HAP_REPR_IID] char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] @@ -320,18 +297,17 @@ async def test_fan_speed(hass, hk_driver, cls, events): ) await hass.async_add_executor_job(acc.char_speed.client_update_value, 42) await hass.async_block_till_done() - acc.speed_mapping.speed_to_states.assert_called_with(42) assert acc.char_speed.value == 42 assert acc.char_active.value == 1 - assert call_set_speed[0] - assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_speed[0].data[ATTR_SPEED] == "ludicrous" + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == "ludicrous" + assert events[-1].data[ATTR_VALUE] == 42 # Verify speed is preserved from off to on - hass.states.async_set(entity_id, STATE_OFF, {ATTR_SPEED: SPEED_OFF}) + hass.states.async_set(entity_id, STATE_OFF, {ATTR_PERCENTAGE: 42}) await hass.async_block_till_done() assert acc.char_speed.value == 42 assert acc.char_active.value == 0 @@ -353,10 +329,9 @@ async def test_fan_speed(hass, hk_driver, cls, events): assert acc.char_active.value == 1 -async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): +async def test_fan_set_all_one_shot(hass, hk_driver, events): """Test fan with speed.""" entity_id = "fan.demo" - speed_list = [SPEED_OFF, SPEED_LOW, SPEED_HIGH] hass.states.async_set( entity_id, @@ -365,29 +340,21 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION, - ATTR_SPEED: SPEED_OFF, + ATTR_PERCENTAGE: 0, ATTR_OSCILLATING: False, ATTR_DIRECTION: DIRECTION_FORWARD, - ATTR_SPEED_LIST: speed_list, }, ) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", entity_id, 1, None) + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) hk_driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # speed to 100 when turning on a fan on a freshly booted up server. assert acc.char_speed.value != 0 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() - assert ( - acc.speed_mapping.speed_ranges == HomeKitSpeedMapping(speed_list).speed_ranges - ) - - acc.speed_mapping.speed_to_homekit = Mock(return_value=42) - acc.speed_mapping.speed_to_states = Mock(return_value="ludicrous") - hass.states.async_set( entity_id, STATE_OFF, @@ -395,17 +362,16 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION, - ATTR_SPEED: SPEED_OFF, + ATTR_PERCENTAGE: 0, ATTR_OSCILLATING: False, ATTR_DIRECTION: DIRECTION_FORWARD, - ATTR_SPEED_LIST: speed_list, }, ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF # Set from HomeKit - call_set_speed = async_mock_service(hass, DOMAIN, "set_speed") + call_set_percentage = async_mock_service(hass, DOMAIN, "set_percentage") call_oscillate = async_mock_service(hass, DOMAIN, "oscillate") call_set_direction = async_mock_service(hass, DOMAIN, "set_direction") call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -444,11 +410,10 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): "mock_addr", ) await hass.async_block_till_done() - acc.speed_mapping.speed_to_states.assert_called_with(42) assert not call_turn_on - assert call_set_speed[0] - assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_speed[0].data[ATTR_SPEED] == "ludicrous" + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 assert call_oscillate[0] assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id assert call_oscillate[0].data[ATTR_OSCILLATING] is True @@ -459,7 +424,7 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): assert events[0].data[ATTR_VALUE] is True assert events[1].data[ATTR_VALUE] == DIRECTION_REVERSE - assert events[2].data[ATTR_VALUE] == "ludicrous" + assert events[2].data[ATTR_VALUE] == 42 hass.states.async_set( entity_id, @@ -468,10 +433,9 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION, - ATTR_SPEED: SPEED_OFF, + ATTR_PERCENTAGE: 0, ATTR_OSCILLATING: False, ATTR_DIRECTION: DIRECTION_FORWARD, - ATTR_SPEED_LIST: speed_list, }, ) await hass.async_block_till_done() @@ -506,11 +470,10 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): # Turn on should not be called if its already on # and we set a fan speed await hass.async_block_till_done() - acc.speed_mapping.speed_to_states.assert_called_with(42) assert len(events) == 6 - assert call_set_speed[1] - assert call_set_speed[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_speed[1].data[ATTR_SPEED] == "ludicrous" + assert call_set_percentage[1] + assert call_set_percentage[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[1].data[ATTR_PERCENTAGE] == 42 assert call_oscillate[1] assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id assert call_oscillate[1].data[ATTR_OSCILLATING] is True @@ -520,7 +483,7 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): assert events[-3].data[ATTR_VALUE] is True assert events[-2].data[ATTR_VALUE] == DIRECTION_REVERSE - assert events[-1].data[ATTR_VALUE] == "ludicrous" + assert events[-1].data[ATTR_VALUE] == 42 hk_driver.set_characteristics( { @@ -554,12 +517,12 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): assert len(events) == 7 assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id - assert len(call_set_speed) == 2 + assert len(call_set_percentage) == 2 assert len(call_oscillate) == 2 assert len(call_set_direction) == 2 -async def test_fan_restore(hass, hk_driver, cls, events): +async def test_fan_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -584,16 +547,96 @@ async def test_fan_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.fan(hass, hk_driver, "Fan", "fan.simple", 2, None) + acc = Fan(hass, hk_driver, "Fan", "fan.simple", 2, None) assert acc.category == 3 assert acc.char_active is not None assert acc.char_direction is None assert acc.char_speed is None assert acc.char_swing is None - acc = cls.fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None) + acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None) assert acc.category == 3 assert acc.char_active is not None assert acc.char_direction is not None assert acc.char_speed is not None assert acc.char_swing is not None + + +async def test_fan_preset_modes(hass, hk_driver, events): + """Test fan with direction.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_PRESET_MODE, + ATTR_PRESET_MODE: "auto", + ATTR_PRESET_MODES: ["auto", "smart"], + }, + ) + await hass.async_block_till_done() + acc = Fan(hass, hk_driver, "Fan", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["auto"].value == 1 + assert acc.preset_mode_chars["smart"].value == 0 + + await acc.run() + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_PRESET_MODE, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: ["auto", "smart"], + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["auto"].value == 0 + assert acc.preset_mode_chars["smart"].value == 1 + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, DOMAIN, "set_preset_mode") + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + char_auto_iid = acc.preset_mode_chars["auto"].to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_preset_mode[0] + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto" + assert len(events) == 1 + assert events[-1].data["service"] == "set_preset_mode" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert events[-1].data["service"] == "turn_on" + assert len(events) == 2 diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index 51f9621d15a..1a301e340b3 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -54,7 +54,7 @@ async def test_humidifier(hass, hk_driver, events): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 1 @@ -135,7 +135,7 @@ async def test_dehumidifier(hass, hk_driver, events): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 1 @@ -220,7 +220,7 @@ async def test_hygrostat_power_state(hass, hk_driver, events): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_current_humidifier_dehumidifier.value == 2 @@ -298,7 +298,7 @@ async def test_hygrostat_get_humidity_range(hass, hk_driver): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_target_humidity.properties[PROP_MAX_VALUE] == 45 @@ -332,7 +332,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_current_humidity.value == 42.0 @@ -384,7 +384,7 @@ async def test_humidifier_with_a_missing_linked_humidity_sensor(hass, hk_driver) ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_current_humidity.value == 0 @@ -401,7 +401,7 @@ async def test_humidifier_as_dehumidifier(hass, hk_driver, events, caplog): ) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_target_humidifier_dehumidifier.value == 1 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index e82bc5bb15d..0ab3ef8e45d 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,10 +1,9 @@ """Test different accessory types: Lights.""" -from collections import namedtuple from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE -import pytest from homeassistant.components.homekit.const import ATTR_VALUE +from homeassistant.components.homekit.type_lights import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -28,36 +27,22 @@ from homeassistant.core import CoreState from homeassistant.helpers import entity_registry from tests.common import async_mock_service -from tests.components.homekit.common import patch_debounce -@pytest.fixture(scope="module") -def cls(): - """Patch debounce decorator during import of type_lights.""" - patcher = patch_debounce() - patcher.start() - _import = __import__( - "homeassistant.components.homekit.type_lights", fromlist=["Light"] - ) - patcher_tuple = namedtuple("Cls", ["light"]) - yield patcher_tuple(light=_import.Light) - patcher.stop() - - -async def test_light_basic(hass, hk_driver, cls, events): +async def test_light_basic(hass, hk_driver, events): """Test light with char state.""" entity_id = "light.demo" hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.aid == 1 assert acc.category == 5 # Lightbulb assert acc.char_on.value - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_on.value == 1 @@ -113,7 +98,7 @@ async def test_light_basic(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "Set state to 0" -async def test_light_brightness(hass, hk_driver, cls, events): +async def test_light_brightness(hass, hk_driver, events): """Test light with brightness.""" entity_id = "light.demo" @@ -123,7 +108,7 @@ async def test_light_brightness(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the @@ -132,7 +117,7 @@ async def test_light_brightness(hass, hk_driver, cls, events): char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_brightness.value == 100 @@ -231,7 +216,7 @@ async def test_light_brightness(hass, hk_driver, cls, events): assert acc.char_brightness.value == 1 -async def test_light_color_temperature(hass, hk_driver, cls, events): +async def test_light_color_temperature(hass, hk_driver, events): """Test light with color temperature.""" entity_id = "light.demo" @@ -241,12 +226,12 @@ async def test_light_color_temperature(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.char_color_temperature.value == 190 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_color_temperature.value == 190 @@ -278,7 +263,7 @@ async def test_light_color_temperature(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "color temperature at 250" -async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, events): +async def test_light_color_temperature_and_rgb_color(hass, hk_driver, events): """Test light with color temperature and rgb color not exposing temperature.""" entity_id = "light.demo" @@ -292,7 +277,7 @@ async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, event }, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None) + acc = Light(hass, hk_driver, "Light", entity_id, 2, None) assert acc.char_hue.value == 260 assert acc.char_saturation.value == 90 @@ -300,20 +285,20 @@ async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, event hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) await hass.async_block_till_done() - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_hue.value == 27 assert acc.char_saturation.value == 27 hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352}) await hass.async_block_till_done() - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_hue.value == 28 assert acc.char_saturation.value == 61 -async def test_light_rgb_color(hass, hk_driver, cls, events): +async def test_light_rgb_color(hass, hk_driver, events): """Test light with rgb_color.""" entity_id = "light.demo" @@ -323,13 +308,13 @@ async def test_light_rgb_color(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) assert acc.char_hue.value == 260 assert acc.char_saturation.value == 90 - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_hue.value == 260 assert acc.char_saturation.value == 90 @@ -365,7 +350,7 @@ async def test_light_rgb_color(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" -async def test_light_restore(hass, hk_driver, cls, events): +async def test_light_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -385,20 +370,20 @@ async def test_light_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", "light.simple", 1, None) + acc = Light(hass, hk_driver, "Light", "light.simple", 1, None) hk_driver.add_accessory(acc) assert acc.category == 5 # Lightbulb assert acc.chars == [] assert acc.char_on.value == 0 - acc = cls.light(hass, hk_driver, "Light", "light.all_info_set", 2, None) + acc = Light(hass, hk_driver, "Light", "light.all_info_set", 2, None) assert acc.category == 5 # Lightbulb assert acc.chars == ["Brightness"] assert acc.char_on.value == 0 -async def test_light_set_brightness_and_color(hass, hk_driver, cls, events): +async def test_light_set_brightness_and_color(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo" @@ -411,7 +396,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, cls, events): }, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the @@ -422,7 +407,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, cls, events): char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_brightness.value == 100 @@ -474,7 +459,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, cls, events): ) -async def test_light_set_brightness_and_color_temp(hass, hk_driver, cls, events): +async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo" @@ -487,7 +472,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, cls, events) }, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the @@ -497,7 +482,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, cls, events) char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_brightness.value == 100 diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 7899af36995..b2bb9b4736e 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -24,7 +24,7 @@ async def test_lock_unlock(hass, hk_driver, events): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = Lock(hass, hk_driver, "Lock", entity_id, 2, config) - await acc.run_handler() + await acc.run() assert acc.aid == 2 assert acc.category == 6 # DoorLock diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 9516963a982..0b9d25ce3ec 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -61,7 +61,7 @@ async def test_media_player_set_state(hass, hk_driver, events): ) await hass.async_block_till_done() acc = MediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, config) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -203,7 +203,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -375,7 +375,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.chars_tv == [CHAR_REMOTE_KEY] @@ -411,7 +411,7 @@ async def test_media_player_television_supports_source_select_no_sources( ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.support_select_source is False diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index d6bf74bb7cf..19b8b5720e2 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -34,7 +34,7 @@ async def test_switch_set_state(hass, hk_driver, events): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -238,7 +238,7 @@ async def test_supported_states(hass, hk_driver, events): await hass.async_block_till_done() acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() valid_current_values = acc.char_current_state.properties.get("ValidValues") diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 7ee79352d7b..fe2ae7566d5 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -40,7 +40,7 @@ async def test_temperature(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = TemperatureSensor(hass, hk_driver, "Temperature", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -74,7 +74,7 @@ async def test_humidity(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = HumiditySensor(hass, hk_driver, "Humidity", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -98,7 +98,7 @@ async def test_air_quality(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = AirQualitySensor(hass, hk_driver, "Air Quality", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -130,7 +130,7 @@ async def test_co(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = CarbonMonoxideSensor(hass, hk_driver, "CO", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -170,7 +170,7 @@ async def test_co2(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = CarbonDioxideSensor(hass, hk_driver, "CO2", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -210,7 +210,7 @@ async def test_light(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = LightSensor(hass, hk_driver, "Light", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -235,7 +235,7 @@ async def test_binary(hass, hk_driver): await hass.async_block_till_done() acc = BinarySensor(hass, hk_driver, "Window Opening", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -274,7 +274,7 @@ async def test_motion_uses_bool(hass, hk_driver): await hass.async_block_till_done() acc = BinarySensor(hass, hk_driver, "Motion Sensor", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 5d218a6ef8a..2ce0acfc8bc 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -42,7 +42,7 @@ async def test_outlet_set_state(hass, hk_driver, events): hass.states.async_set(entity_id, None) await hass.async_block_till_done() acc = Outlet(hass, hk_driver, "Outlet", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -95,7 +95,7 @@ async def test_switch_set_state(hass, hk_driver, entity_id, attrs, events): hass.states.async_set(entity_id, None, attrs) await hass.async_block_till_done() acc = Switch(hass, hk_driver, "Switch", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -139,25 +139,25 @@ async def test_valve_set_state(hass, hk_driver, events): await hass.async_block_till_done() acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_FAUCET}) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.category == 29 # Faucet assert acc.char_valve_type.value == 3 # Water faucet acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SHOWER}) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.category == 30 # Shower assert acc.char_valve_type.value == 2 # Shower head acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SPRINKLER}) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.category == 28 # Sprinkler assert acc.char_valve_type.value == 1 # Irrigation acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_VALVE}) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -210,7 +210,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( await hass.async_block_till_done() acc = Vacuum(hass, hk_driver, "Vacuum", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 assert acc.category == 8 # Switch @@ -266,7 +266,7 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( await hass.async_block_till_done() acc = Vacuum(hass, hk_driver, "Vacuum", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 assert acc.category == 8 # Switch @@ -310,7 +310,7 @@ async def test_reset_switch(hass, hk_driver, events): 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_handler() + await acc.run() await hass.async_block_till_done() assert acc.activate_only is True @@ -347,7 +347,7 @@ async def test_reset_switch_reload(hass, hk_driver, events): 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_handler() + await acc.run() await hass.async_block_till_done() assert acc.activate_only is False diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 79b5ca21097..7d3d0d14c2f 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -89,7 +89,7 @@ async def test_thermostat(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 1 @@ -431,7 +431,7 @@ async def test_thermostat_auto(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_cooling_thresh_temp.value == 23.0 @@ -570,7 +570,7 @@ async def test_thermostat_humidity(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_target_humidity.value == 50 @@ -645,7 +645,7 @@ async def test_thermostat_power_state(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_current_heat_cool.value == 1 @@ -756,7 +756,7 @@ async def test_thermostat_fahrenheit(hass, hk_driver, events): with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hass.states.async_set( @@ -879,7 +879,7 @@ async def test_thermostat_temperature_step_whole(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 @@ -942,7 +942,7 @@ async def test_thermostat_hvac_modes(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 1] @@ -985,7 +985,7 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 1, 3] @@ -1041,7 +1041,7 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 1, 3] @@ -1095,7 +1095,7 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [0, 3] @@ -1149,7 +1149,7 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] @@ -1209,7 +1209,7 @@ async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_COOL] @@ -1273,7 +1273,7 @@ async def test_thermostat_hvac_modes_with_heat_cool_only(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [ @@ -1362,7 +1362,7 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [1, 3] @@ -1401,7 +1401,7 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, events acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_cooling_thresh_temp.value == 23.0 @@ -1576,7 +1576,7 @@ async def test_water_heater(hass, hk_driver, events): hass.states.async_set(entity_id, HVAC_MODE_HEAT) await hass.async_block_till_done() acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.aid == 2 @@ -1655,7 +1655,7 @@ async def test_water_heater_fahrenheit(hass, hk_driver, events): await hass.async_block_till_done() with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() hass.states.async_set(entity_id, HVAC_MODE_HEAT, {ATTR_TEMPERATURE: 131}) @@ -1762,7 +1762,7 @@ async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, event acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_cooling_thresh_temp.value == 23.0 @@ -1815,7 +1815,7 @@ async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_cooling_thresh_temp.value == 23.0 @@ -1869,7 +1869,7 @@ async def test_thermostat_with_temp_clamps(hass, hk_driver, events): acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) - await acc.run_handler() + await acc.run() await hass.async_block_till_done() assert acc.char_cooling_thresh_temp.value == 100 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index c6845779313..9b03d616002 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -1,8 +1,11 @@ """Test HomeKit util module.""" +from unittest.mock import Mock + import pytest import voluptuous as vol from homeassistant.components.homekit.const import ( + BRIDGE_NAME, CONF_FEATURE, CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR, @@ -21,13 +24,12 @@ from homeassistant.components.homekit.const import ( TYPE_VALVE, ) from homeassistant.components.homekit.util import ( - HomeKitSpeedMapping, - SpeedRange, + accessory_friendly_name, + async_find_next_available_port, cleanup_name_for_homekit, convert_to_float, density_to_air_quality, dismiss_setup_message, - find_next_available_port, format_sw_version, port_is_available, show_setup_message, @@ -45,6 +47,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, + CONF_PORT, CONF_TYPE, STATE_UNKNOWN, TEMP_CELSIUS, @@ -54,7 +57,7 @@ from homeassistant.core import State from .util import async_init_integration -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service def test_validate_entity_config(): @@ -251,73 +254,32 @@ async def test_dismiss_setup_msg(hass): assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == "entry_id" -def test_homekit_speed_mapping(): - """Test if the SpeedRanges from a speed_list are as expected.""" - # A standard 2-speed fan - speed_mapping = HomeKitSpeedMapping(["off", "low", "high"]) - assert speed_mapping.speed_ranges == { - "off": SpeedRange(0, 0), - "low": SpeedRange(100 / 3, 50), - "high": SpeedRange(200 / 3, 100), - } - - # A standard 3-speed fan - speed_mapping = HomeKitSpeedMapping(["off", "low", "medium", "high"]) - assert speed_mapping.speed_ranges == { - "off": SpeedRange(0, 0), - "low": SpeedRange(100 / 4, 100 / 3), - "medium": SpeedRange(200 / 4, 200 / 3), - "high": SpeedRange(300 / 4, 100), - } - - # a Dyson-like fan with 10 speeds - speed_mapping = HomeKitSpeedMapping([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) - assert speed_mapping.speed_ranges == { - 0: SpeedRange(0, 0), - 1: SpeedRange(10, 100 / 9), - 2: SpeedRange(20, 200 / 9), - 3: SpeedRange(30, 300 / 9), - 4: SpeedRange(40, 400 / 9), - 5: SpeedRange(50, 500 / 9), - 6: SpeedRange(60, 600 / 9), - 7: SpeedRange(70, 700 / 9), - 8: SpeedRange(80, 800 / 9), - 9: SpeedRange(90, 100), - } - - -def test_speed_to_homekit(): - """Test speed conversion from HA to Homekit.""" - speed_mapping = HomeKitSpeedMapping(["off", "low", "high"]) - assert speed_mapping.speed_to_homekit(None) is None - assert speed_mapping.speed_to_homekit("off") == 0 - assert speed_mapping.speed_to_homekit("low") == 50 - assert speed_mapping.speed_to_homekit("high") == 100 - - -def test_speed_to_states(): - """Test speed conversion from Homekit to HA.""" - speed_mapping = HomeKitSpeedMapping(["off", "low", "high"]) - assert speed_mapping.speed_to_states(-1) == "off" - assert speed_mapping.speed_to_states(0) == "off" - assert speed_mapping.speed_to_states(33) == "off" - assert speed_mapping.speed_to_states(34) == "low" - assert speed_mapping.speed_to_states(50) == "low" - assert speed_mapping.speed_to_states(66) == "low" - assert speed_mapping.speed_to_states(67) == "high" - assert speed_mapping.speed_to_states(100) == "high" - - async def test_port_is_available(hass): """Test we can get an available port and it is actually available.""" - next_port = await hass.async_add_executor_job( - find_next_available_port, DEFAULT_CONFIG_FLOW_PORT - ) + next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + assert next_port assert await hass.async_add_executor_job(port_is_available, next_port) +async def test_port_is_available_skips_existing_entries(hass): + """Test we can get an available port and it is actually available.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_CONFIG_FLOW_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + + assert next_port + assert next_port != DEFAULT_CONFIG_FLOW_PORT + + assert await hass.async_add_executor_job(port_is_available, next_port) + + async def test_format_sw_version(): """Test format_sw_version method.""" assert format_sw_version("soho+3.6.8+soho-release-rt120+10") == "3.6.8" @@ -325,3 +287,12 @@ async def test_format_sw_version(): assert format_sw_version("56.0-76060") == "56.0.76060" assert format_sw_version(3.6) == "3.6" assert format_sw_version("unknown") is None + + +async def test_accessory_friendly_name(): + """Test we provide a helpful friendly name.""" + + accessory = Mock() + accessory.display_name = "same" + assert accessory_friendly_name("same", accessory) == "same" + assert accessory_friendly_name("hass title", accessory) == "hass title (same)" diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index cd3f57137bf..ebc50fda8bc 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -63,6 +63,7 @@ async def test_lg_tv(hass): assert device.sw_version == "04.71.04" assert device.via_device_id is None - # A TV doesn't have any triggers + # A TV has media player device triggers triggers = await async_get_device_automations(hass, "trigger", device.id) - assert triggers == [] + for trigger in triggers: + assert trigger["domain"] == "media_player" diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index e9ebce4045b..b8d42b21643 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -83,7 +83,7 @@ async def test_turn_on(hass, utcnow): blocking=True, ) assert helper.characteristics[V1_ON].value == 1 - assert helper.characteristics[V1_ROTATION_SPEED].value == 50 + assert helper.characteristics[V1_ROTATION_SPEED].value == 66.0 await hass.services.async_call( "fan", @@ -92,7 +92,7 @@ async def test_turn_on(hass, utcnow): blocking=True, ) assert helper.characteristics[V1_ON].value == 1 - assert helper.characteristics[V1_ROTATION_SPEED].value == 25 + assert helper.characteristics[V1_ROTATION_SPEED].value == 33.0 async def test_turn_off(hass, utcnow): @@ -130,7 +130,7 @@ async def test_set_speed(hass, utcnow): {"entity_id": "fan.testdevice", "speed": "medium"}, blocking=True, ) - assert helper.characteristics[V1_ROTATION_SPEED].value == 50 + assert helper.characteristics[V1_ROTATION_SPEED].value == 66.0 await hass.services.async_call( "fan", @@ -138,7 +138,7 @@ async def test_set_speed(hass, utcnow): {"entity_id": "fan.testdevice", "speed": "low"}, blocking=True, ) - assert helper.characteristics[V1_ROTATION_SPEED].value == 25 + assert helper.characteristics[V1_ROTATION_SPEED].value == 33.0 await hass.services.async_call( "fan", @@ -149,6 +149,29 @@ async def test_set_speed(hass, utcnow): assert helper.characteristics[V1_ON].value == 0 +async def test_set_percentage(hass, utcnow): + """Test that we set fan speed by percentage.""" + helper = await setup_test_component(hass, create_fan_service) + + helper.characteristics[V1_ON].value = 1 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + assert helper.characteristics[V1_ROTATION_SPEED].value == 66 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + assert helper.characteristics[V1_ON].value == 0 + + async def test_speed_read(hass, utcnow): """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -157,19 +180,23 @@ async def test_speed_read(hass, utcnow): helper.characteristics[V1_ROTATION_SPEED].value = 100 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "high" + assert state.attributes["percentage"] == 100 helper.characteristics[V1_ROTATION_SPEED].value = 50 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "medium" + assert state.attributes["percentage"] == 50 helper.characteristics[V1_ROTATION_SPEED].value = 25 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "low" + assert state.attributes["percentage"] == 25 helper.characteristics[V1_ON].value = 0 helper.characteristics[V1_ROTATION_SPEED].value = 0 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "off" + assert state.attributes["percentage"] == 0 async def test_set_direction(hass, utcnow): @@ -239,7 +266,7 @@ async def test_v2_turn_on(hass, utcnow): blocking=True, ) assert helper.characteristics[V2_ACTIVE].value == 1 - assert helper.characteristics[V2_ROTATION_SPEED].value == 50 + assert helper.characteristics[V2_ROTATION_SPEED].value == 66.0 await hass.services.async_call( "fan", @@ -248,7 +275,7 @@ async def test_v2_turn_on(hass, utcnow): blocking=True, ) assert helper.characteristics[V2_ACTIVE].value == 1 - assert helper.characteristics[V2_ROTATION_SPEED].value == 25 + assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0 async def test_v2_turn_off(hass, utcnow): @@ -286,7 +313,7 @@ async def test_v2_set_speed(hass, utcnow): {"entity_id": "fan.testdevice", "speed": "medium"}, blocking=True, ) - assert helper.characteristics[V2_ROTATION_SPEED].value == 50 + assert helper.characteristics[V2_ROTATION_SPEED].value == 66 await hass.services.async_call( "fan", @@ -294,7 +321,7 @@ async def test_v2_set_speed(hass, utcnow): {"entity_id": "fan.testdevice", "speed": "low"}, blocking=True, ) - assert helper.characteristics[V2_ROTATION_SPEED].value == 25 + assert helper.characteristics[V2_ROTATION_SPEED].value == 33 await hass.services.async_call( "fan", @@ -305,6 +332,29 @@ async def test_v2_set_speed(hass, utcnow): assert helper.characteristics[V2_ACTIVE].value == 0 +async def test_v2_set_percentage(hass, utcnow): + """Test that we set fan speed by percentage.""" + helper = await setup_test_component(hass, create_fanv2_service) + + helper.characteristics[V2_ACTIVE].value = 1 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + assert helper.characteristics[V2_ROTATION_SPEED].value == 66 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 0 + + async def test_v2_speed_read(hass, utcnow): """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -313,19 +363,23 @@ async def test_v2_speed_read(hass, utcnow): helper.characteristics[V2_ROTATION_SPEED].value = 100 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "high" + assert state.attributes["percentage"] == 100 helper.characteristics[V2_ROTATION_SPEED].value = 50 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "medium" + assert state.attributes["percentage"] == 50 helper.characteristics[V2_ROTATION_SPEED].value = 25 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "low" + assert state.attributes["percentage"] == 25 helper.characteristics[V2_ACTIVE].value = 0 helper.characteristics[V2_ROTATION_SPEED].value = 0 state = await helper.poll_and_get_state() assert state.attributes["speed"] == "off" + assert state.attributes["percentage"] == 0 async def test_v2_set_direction(hass, utcnow): diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index e443e36b910..f4950512063 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -3,6 +3,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from homeassistant.const import STATE_UNAVAILABLE from tests.components.homekit_controller.common import setup_test_component @@ -209,8 +210,8 @@ async def test_light_becomes_unavailable_but_recovers(hass, utcnow): assert state.attributes["color_temp"] == 400 -async def test_light_unloaded(hass, utcnow): - """Test entity and HKDevice are correctly unloaded.""" +async def test_light_unloaded_removed(hass, utcnow): + """Test entity and HKDevice are correctly unloaded and removed.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) # Initial state is that the light is off @@ -220,9 +221,15 @@ async def test_light_unloaded(hass, utcnow): unload_result = await helper.config_entry.async_unload(hass) assert unload_result is True - # Make sure entity is unloaded - assert hass.states.get(helper.entity_id) is None + # Make sure entity is set to unavailable state + assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE # Make sure HKDevice is no longer set to poll this accessory conn = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] assert not conn.pollable_characteristics + + await helper.config_entry.async_remove(hass) + await hass.async_block_till_done() + + # Make sure entity is removed + assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 3dd587cd7a4..993f0dba1fd 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -60,73 +60,6 @@ async def test_registering_view_while_running( hass.http.register_view(TestView) -def test_api_base_url_with_domain(mock_stack): - """Test setting API URL with domain.""" - api_config = http.ApiConfig("127.0.0.1", "example.com") - assert api_config.base_url == "http://example.com:8123" - - -def test_api_base_url_with_ip(mock_stack): - """Test setting API URL with IP.""" - api_config = http.ApiConfig("127.0.0.1", "1.1.1.1") - assert api_config.base_url == "http://1.1.1.1:8123" - - -def test_api_base_url_with_ip_and_port(mock_stack): - """Test setting API URL with IP and port.""" - api_config = http.ApiConfig("127.0.0.1", "1.1.1.1", 8124) - assert api_config.base_url == "http://1.1.1.1:8124" - - -def test_api_base_url_with_protocol(mock_stack): - """Test setting API URL with protocol.""" - api_config = http.ApiConfig("127.0.0.1", "https://example.com") - assert api_config.base_url == "https://example.com:8123" - - -def test_api_base_url_with_protocol_and_port(mock_stack): - """Test setting API URL with protocol and port.""" - api_config = http.ApiConfig("127.0.0.1", "https://example.com", 433) - assert api_config.base_url == "https://example.com:433" - - -def test_api_base_url_with_ssl_enable(mock_stack): - """Test setting API URL with use_ssl enabled.""" - api_config = http.ApiConfig("127.0.0.1", "example.com", use_ssl=True) - assert api_config.base_url == "https://example.com:8123" - - -def test_api_base_url_with_ssl_enable_and_port(mock_stack): - """Test setting API URL with use_ssl enabled and port.""" - api_config = http.ApiConfig("127.0.0.1", "1.1.1.1", use_ssl=True, port=8888) - assert api_config.base_url == "https://1.1.1.1:8888" - - -def test_api_base_url_with_protocol_and_ssl_enable(mock_stack): - """Test setting API URL with specific protocol and use_ssl enabled.""" - api_config = http.ApiConfig("127.0.0.1", "http://example.com", use_ssl=True) - assert api_config.base_url == "http://example.com:8123" - - -def test_api_base_url_removes_trailing_slash(mock_stack): - """Test a trialing slash is removed when setting the API URL.""" - api_config = http.ApiConfig("127.0.0.1", "http://example.com/") - assert api_config.base_url == "http://example.com:8123" - - -def test_api_local_ip(mock_stack): - """Test a trialing slash is removed when setting the API URL.""" - api_config = http.ApiConfig("127.0.0.1", "http://example.com/") - assert api_config.local_ip == "127.0.0.1" - - -async def test_api_no_base_url(hass, mock_stack): - """Test setting api url.""" - result = await async_setup_component(hass, "http", {"http": {}}) - assert result - assert hass.config.api.base_url == "http://127.0.0.1:8123" - - async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, "api", {"http": {}}) @@ -260,127 +193,3 @@ async def test_storing_config(hass, aiohttp_client, aiohttp_unused_port): restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) assert restored == http.HTTP_SCHEMA(config) - - -async def test_use_of_base_url(hass): - """Test detection base_url usage when called without integration context.""" - await async_setup_component(hass, "http", {"http": {}}) - with patch( - "homeassistant.components.http.extract_stack", - return_value=[ - Mock( - filename="/home/frenck/homeassistant/core.py", - lineno="21", - line="do_something()", - ), - Mock( - filename="/home/frenck/homeassistant/core.py", - lineno="42", - line="url = hass.config.api.base_url", - ), - Mock( - filename="/home/frenck/example/client.py", - lineno="21", - line="something()", - ), - ], - ), pytest.raises(RuntimeError): - hass.config.api.base_url - - -async def test_use_of_base_url_integration(hass, caplog): - """Test detection base_url usage when called with integration context.""" - await async_setup_component(hass, "http", {"http": {}}) - with patch( - "homeassistant.components.http.extract_stack", - return_value=[ - Mock( - filename="/home/frenck/homeassistant/core.py", - lineno="21", - line="do_something()", - ), - Mock( - filename="/home/frenck/homeassistant/components/example/__init__.py", - lineno="42", - line="url = hass.config.api.base_url", - ), - Mock( - filename="/home/frenck/example/client.py", - lineno="21", - line="something()", - ), - ], - ): - assert hass.config.api.base_url == "http://127.0.0.1:8123" - - assert ( - "Detected use of deprecated `base_url` property, use `homeassistant.helpers.network.get_url` method instead. Please report issue for example using this method at homeassistant/components/example/__init__.py, line 42: url = hass.config.api.base_url" - in caplog.text - ) - - -async def test_use_of_base_url_integration_webhook(hass, caplog): - """Test detection base_url usage when called with integration context.""" - await async_setup_component(hass, "http", {"http": {}}) - with patch( - "homeassistant.components.http.extract_stack", - return_value=[ - Mock( - filename="/home/frenck/homeassistant/core.py", - lineno="21", - line="do_something()", - ), - Mock( - filename="/home/frenck/homeassistant/components/example/__init__.py", - lineno="42", - line="url = hass.config.api.base_url", - ), - Mock( - filename="/home/frenck/homeassistant/components/webhook/__init__.py", - lineno="42", - line="return get_url(hass)", - ), - Mock( - filename="/home/frenck/example/client.py", - lineno="21", - line="something()", - ), - ], - ): - assert hass.config.api.base_url == "http://127.0.0.1:8123" - - assert ( - "Detected use of deprecated `base_url` property, use `homeassistant.helpers.network.get_url` method instead. Please report issue for example using this method at homeassistant/components/example/__init__.py, line 42: url = hass.config.api.base_url" - in caplog.text - ) - - -async def test_use_of_base_url_custom_component(hass, caplog): - """Test detection base_url usage when called with custom component context.""" - await async_setup_component(hass, "http", {"http": {}}) - with patch( - "homeassistant.components.http.extract_stack", - return_value=[ - Mock( - filename="/home/frenck/homeassistant/core.py", - lineno="21", - line="do_something()", - ), - Mock( - filename="/home/frenck/.homeassistant/custom_components/example/__init__.py", - lineno="42", - line="url = hass.config.api.base_url", - ), - Mock( - filename="/home/frenck/example/client.py", - lineno="21", - line="something()", - ), - ], - ): - assert hass.config.api.base_url == "http://127.0.0.1:8123" - - assert ( - "Detected use of deprecated `base_url` property, use `homeassistant.helpers.network.get_url` method instead. Please report issue to the custom component author for example using this method at custom_components/example/__init__.py, line 42: url = hass.config.api.base_url" - in caplog.text - ) diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 3e6465d6bc8..29bc2acf03a 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -192,6 +192,41 @@ async def test_hue_activate_scene(hass, mock_api): 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", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + 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) 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( diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index c7dc83183ae..57f4bd7fbca 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -640,6 +640,15 @@ async def test_options_flow(hass): assert result["type"] == "form" assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert ( + _get_schema_default(schema, const.CONF_ALLOW_HUE_GROUPS) + == const.DEFAULT_ALLOW_HUE_GROUPS + ) + assert ( + _get_schema_default(schema, const.CONF_ALLOW_UNREACHABLE) + == const.DEFAULT_ALLOW_UNREACHABLE + ) result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -654,3 +663,11 @@ async def test_options_flow(hass): const.CONF_ALLOW_HUE_GROUPS: True, const.CONF_ALLOW_UNREACHABLE: True, } + + +def _get_schema_default(schema, key_name): + """Iterate schema to find a key.""" + for schema_key in schema: + if schema_key == key_name: + return schema_key.default() + raise KeyError(f"{key_name} not found in schema") diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index 629a9a4c98b..39b9a5a23fc 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -7,6 +7,12 @@ import aiohue from homeassistant import config_entries from homeassistant.components import hue from homeassistant.components.hue import light as hue_light +from homeassistant.helpers.device_registry import ( + async_get_registry as async_get_device_registry, +) +from homeassistant.helpers.entity_registry import ( + async_get_registry as async_get_entity_registry, +) from homeassistant.util import color HUE_LIGHT_NS = "homeassistant.components.light.hue." @@ -211,8 +217,10 @@ async def test_no_lights_or_groups(hass, mock_bridge): async def test_lights(hass, mock_bridge): """Test the update_lights function with some lights.""" 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) == 1 + assert len(mock_bridge.mock_requests) == 2 # 2 lights assert len(hass.states.async_all()) == 2 @@ -230,6 +238,8 @@ async def test_lights(hass, mock_bridge): async def test_lights_color_mode(hass, mock_bridge): """Test that lights only report appropriate color mode.""" mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + await setup_bridge(hass, mock_bridge) lamp_1 = hass.states.get("light.hue_lamp_1") @@ -249,8 +259,8 @@ async def test_lights_color_mode(hass, mock_bridge): await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_2"}, blocking=True ) - # 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 3 + # 2x light update, 1 group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 lamp_1 = hass.states.get("light.hue_lamp_1") assert lamp_1 is not None @@ -332,9 +342,10 @@ async def test_new_group_discovered(hass, mock_bridge): async def test_new_light_discovered(hass, mock_bridge): """Test if 2nd update has a new light.""" 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) == 1 + assert len(mock_bridge.mock_requests) == 2 assert len(hass.states.async_all()) == 2 new_light_response = dict(LIGHT_RESPONSE) @@ -366,8 +377,8 @@ async def test_new_light_discovered(hass, mock_bridge): await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) - # 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 3 + # 2x light update, 1 group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 3 light = hass.states.get("light.hue_lamp_3") @@ -407,9 +418,10 @@ async def test_group_removed(hass, mock_bridge): async def test_light_removed(hass, mock_bridge): """Test if 2nd update has removed light.""" 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) == 1 + assert len(mock_bridge.mock_requests) == 2 assert len(hass.states.async_all()) == 2 mock_bridge.mock_light_responses.clear() @@ -420,8 +432,8 @@ async def test_light_removed(hass, mock_bridge): "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) - # 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 3 + # 2x light update, 1 group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 1 light = hass.states.get("light.hue_lamp_1") @@ -487,9 +499,10 @@ async def test_other_group_update(hass, mock_bridge): async def test_other_light_update(hass, mock_bridge): """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) await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + assert len(mock_bridge.mock_requests) == 2 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -526,8 +539,8 @@ async def test_other_light_update(hass, mock_bridge): await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) - # 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 3 + # 2x light update, 1 group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -549,7 +562,6 @@ async def test_update_timeout(hass, mock_bridge): async def test_update_unauthorized(hass, mock_bridge): """Test bridge marked as not authorized if unauthorized during update.""" mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) - mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized) await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 @@ -559,6 +571,8 @@ async def test_update_unauthorized(hass, mock_bridge): async def test_light_turn_on_service(hass, mock_bridge): """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) + await setup_bridge(hass, mock_bridge) light = hass.states.get("light.hue_lamp_2") assert light is not None @@ -575,10 +589,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 turn on request - assert len(mock_bridge.mock_requests) == 3 + # 2x light update, 1 group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 - assert mock_bridge.mock_requests[1]["json"] == { + assert mock_bridge.mock_requests[2]["json"] == { "bri": 100, "on": True, "ct": 300, @@ -599,9 +613,9 @@ async def test_light_turn_on_service(hass, mock_bridge): blocking=True, ) - assert len(mock_bridge.mock_requests) == 5 + assert len(mock_bridge.mock_requests) == 6 - assert mock_bridge.mock_requests[3]["json"] == { + assert mock_bridge.mock_requests[4]["json"] == { "on": True, "xy": (0.138, 0.08), "alert": "none", @@ -611,6 +625,8 @@ async def test_light_turn_on_service(hass, mock_bridge): async def test_light_turn_off_service(hass, mock_bridge): """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) + await setup_bridge(hass, mock_bridge) light = hass.states.get("light.hue_lamp_1") assert light is not None @@ -624,10 +640,11 @@ async def test_light_turn_off_service(hass, mock_bridge): await hass.services.async_call( "light", "turn_off", {"entity_id": "light.hue_lamp_1"}, blocking=True ) - # 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 3 - assert mock_bridge.mock_requests[1]["json"] == {"on": False, "alert": "none"} + # 2x light update, 1 for group update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 + + assert mock_bridge.mock_requests[2]["json"] == {"on": False, "alert": "none"} assert len(hass.states.async_all()) == 2 @@ -649,6 +666,7 @@ def test_available(): bridge=Mock(allow_unreachable=False), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.available is False @@ -664,6 +682,7 @@ def test_available(): bridge=Mock(allow_unreachable=True), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.available is True @@ -679,6 +698,7 @@ def test_available(): bridge=Mock(allow_unreachable=False), is_group=True, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.available is True @@ -697,6 +717,7 @@ def test_hs_color(): bridge=Mock(), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.hs_color is None @@ -712,6 +733,7 @@ def test_hs_color(): bridge=Mock(), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.hs_color is None @@ -727,6 +749,7 @@ def test_hs_color(): bridge=Mock(), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, + rooms={}, ) assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) @@ -742,7 +765,7 @@ async def test_group_features(hass, mock_bridge): "1": { "name": "Group 1", "lights": ["1", "2"], - "type": "Room", + "type": "LightGroup", "action": { "on": True, "bri": 254, @@ -757,8 +780,8 @@ async def test_group_features(hass, mock_bridge): "state": {"any_on": True, "all_on": False}, }, "2": { - "name": "Group 2", - "lights": ["3", "4"], + "name": "Living Room", + "lights": ["2", "3"], "type": "Room", "action": { "on": True, @@ -774,8 +797,8 @@ async def test_group_features(hass, mock_bridge): "state": {"any_on": True, "all_on": False}, }, "3": { - "name": "Group 3", - "lights": ["1", "3"], + "name": "Dining Room", + "lights": ["4"], "type": "Room", "action": { "on": True, @@ -900,6 +923,7 @@ async def test_group_features(hass, mock_bridge): 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 color_temp_feature = hue_light.SUPPORT_HUE["Color temperature light"] extended_color_feature = hue_light.SUPPORT_HUE["Extended color light"] @@ -907,8 +931,27 @@ async def test_group_features(hass, mock_bridge): group_1 = hass.states.get("light.group_1") assert group_1.attributes["supported_features"] == color_temp_feature - group_2 = hass.states.get("light.group_2") + group_2 = hass.states.get("light.living_room") assert group_2.attributes["supported_features"] == extended_color_feature - group_3 = hass.states.get("light.group_3") + group_3 = hass.states.get("light.dining_room") assert group_3.attributes["supported_features"] == extended_color_feature + + entity_registry = await async_get_entity_registry(hass) + device_registry = await async_get_device_registry(hass) + + entry = entity_registry.async_get("light.hue_lamp_1") + device_entry = device_registry.async_get(entry.device_id) + assert device_entry.suggested_area is None + + entry = entity_registry.async_get("light.hue_lamp_2") + device_entry = device_registry.async_get(entry.device_id) + assert device_entry.suggested_area == "Living Room" + + entry = entity_registry.async_get("light.hue_lamp_3") + device_entry = device_registry.async_get(entry.device_id) + assert device_entry.suggested_area == "Living Room" + + entry = entity_registry.async_get("light.hue_lamp_4") + device_entry = device_registry.async_get(entry.device_id) + assert device_entry.suggested_area == "Dining Room" diff --git a/tests/components/huisbaasje/test_init.py b/tests/components/huisbaasje/test_init.py index 96be450f7e4..3de6af83e46 100644 --- a/tests/components/huisbaasje/test_init.py +++ b/tests/components/huisbaasje/test_init.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ENTRY_STATE_SETUP_ERROR, ConfigEntry, ) -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -145,6 +145,14 @@ async def test_unload_entry(hass: HomeAssistant): await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state == ENTRY_STATE_NOT_LOADED entities = hass.states.async_entity_ids("sensor") + assert len(entities) == 14 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + # Remove config entry + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + entities = hass.states.async_entity_ids("sensor") assert len(entities) == 0 # Assert mocks are called diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index e427cf46a83..954d9abc129 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -1,7 +1,6 @@ """Tests for the Hyperion component.""" from __future__ import annotations -import logging from types import TracebackType from typing import Any, Dict, Optional, Type from unittest.mock import AsyncMock, Mock, patch @@ -9,7 +8,6 @@ from unittest.mock import AsyncMock, Mock, patch from hyperion import const from homeassistant.components.hyperion.const import CONF_PRIORITY, DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.typing import HomeAssistantType @@ -24,8 +22,6 @@ TEST_ID = "default" TEST_SYSINFO_ID = "f9aab089-f85a-55cf-b7c1-222a72faebe9" TEST_SYSINFO_VERSION = "2.0.0-alpha.8" TEST_PRIORITY = 180 -TEST_YAML_NAME = f"{TEST_HOST}_{TEST_PORT}_{TEST_INSTANCE}" -TEST_YAML_ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_YAML_NAME}" TEST_ENTITY_ID_1 = "light.test_instance_1" TEST_ENTITY_ID_2 = "light.test_instance_2" TEST_ENTITY_ID_3 = "light.test_instance_3" @@ -66,8 +62,6 @@ TEST_AUTH_NOT_REQUIRED_RESP = { "info": {"required": False}, } -_LOGGER = logging.getLogger(__name__) - class AsyncContextManagerMock(Mock): """An async context manager mock for Hyperion.""" @@ -101,7 +95,7 @@ def create_mock_client() -> Mock: ) mock_client.async_sysinfo_id = AsyncMock(return_value=TEST_SYSINFO_ID) - mock_client.async_sysinfo_version = AsyncMock(return_value=TEST_SYSINFO_ID) + mock_client.async_sysinfo_version = AsyncMock(return_value=TEST_SYSINFO_VERSION) mock_client.async_client_switch_instance = AsyncMock(return_value=True) mock_client.async_client_login = AsyncMock(return_value=True) mock_client.async_get_serverinfo = AsyncMock( diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 776d5b3b25b..beb642792c9 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -1,6 +1,4 @@ """Tests for the Hyperion config flow.""" - -import logging from typing import Any, Dict, Optional from unittest.mock import AsyncMock, patch @@ -14,12 +12,7 @@ from homeassistant.components.hyperion.const import ( DOMAIN, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ( - SOURCE_IMPORT, - SOURCE_REAUTH, - SOURCE_SSDP, - SOURCE_USER, -) +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -46,8 +39,6 @@ from . import ( from tests.common import MockConfigEntry -_LOGGER = logging.getLogger(__name__) - TEST_IP_ADDRESS = "192.168.0.1" TEST_HOST_PORT: Dict[str, Any] = { CONF_HOST: TEST_HOST, @@ -606,56 +597,6 @@ async def test_ssdp_abort_duplicates(hass: HomeAssistantType) -> None: assert result_2["reason"] == "already_in_progress" -async def test_import_success(hass: HomeAssistantType) -> None: - """Check an import flow from the old-style YAML.""" - - client = create_mock_client() - with patch( - "homeassistant.components.hyperion.client.HyperionClient", return_value=client - ): - result = await _init_flow( - hass, - source=SOURCE_IMPORT, - data={ - CONF_HOST: TEST_HOST, - CONF_PORT: TEST_PORT, - }, - ) - await hass.async_block_till_done() - - # No human interaction should be required. - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["handler"] == DOMAIN - assert result["title"] == TEST_TITLE - assert result["data"] == { - CONF_HOST: TEST_HOST, - CONF_PORT: TEST_PORT, - } - - -async def test_import_cannot_connect(hass: HomeAssistantType) -> None: - """Check an import flow that cannot connect.""" - - client = create_mock_client() - client.async_client_connect = AsyncMock(return_value=False) - - with patch( - "homeassistant.components.hyperion.client.HyperionClient", return_value=client - ): - result = await _init_flow( - hass, - source=SOURCE_IMPORT, - data={ - CONF_HOST: TEST_HOST, - CONF_PORT: TEST_PORT, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" - - async def test_options(hass: HomeAssistantType) -> None: """Check an options flow.""" diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 7559af4d3c7..fe4279ed731 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -1,21 +1,11 @@ """Tests for the Hyperion integration.""" -import logging -from types import MappingProxyType from typing import Optional from unittest.mock import AsyncMock, Mock, call, patch from hyperion import const -from homeassistant import setup -from homeassistant.components.hyperion import ( - get_hyperion_unique_id, - light as hyperion_light, -) -from homeassistant.components.hyperion.const import ( - DEFAULT_ORIGIN, - DOMAIN, - TYPE_HYPERION_LIGHT, -) +from homeassistant.components.hyperion import light as hyperion_light +from homeassistant.components.hyperion.const import DEFAULT_ORIGIN, DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -43,7 +33,6 @@ import homeassistant.util.color as color_util from . import ( TEST_AUTH_NOT_REQUIRED_RESP, TEST_AUTH_REQUIRED_RESP, - TEST_CONFIG_ENTRY_OPTIONS, TEST_ENTITY_ID_1, TEST_ENTITY_ID_2, TEST_ENTITY_ID_3, @@ -56,41 +45,15 @@ from . import ( TEST_PRIORITY, TEST_PRIORITY_LIGHT_ENTITY_ID_1, TEST_SYSINFO_ID, - TEST_YAML_ENTITY_ID, - TEST_YAML_NAME, add_test_config_entry, call_registered_callback, create_mock_client, setup_test_config_entry, ) -_LOGGER = logging.getLogger(__name__) - COLOR_BLACK = color_util.COLORS["black"] -async def _setup_entity_yaml(hass: HomeAssistantType, client: AsyncMock = None) -> None: - """Add a test Hyperion entity to hass.""" - client = client or create_mock_client() - with patch( - "homeassistant.components.hyperion.client.HyperionClient", return_value=client - ): - assert await setup.async_setup_component( - hass, - LIGHT_DOMAIN, - { - LIGHT_DOMAIN: { - "platform": "hyperion", - "name": TEST_YAML_NAME, - "host": TEST_HOST, - "port": TEST_PORT, - "priority": TEST_PRIORITY, - } - }, - ) - await hass.async_block_till_done() - - def _get_config_entry_from_unique_id( hass: HomeAssistantType, unique_id: str ) -> Optional[ConfigEntry]: @@ -100,127 +63,6 @@ def _get_config_entry_from_unique_id( return None -async def test_setup_yaml_already_converted(hass: HomeAssistantType) -> None: - """Test an already converted YAML style config.""" - # This tests "Possibility 1" from async_setup_platform() - - # Add a pre-existing config entry. - add_test_config_entry(hass) - client = create_mock_client() - await _setup_entity_yaml(hass, client=client) - assert client.async_client_disconnect.called - - # Setup should be skipped for the YAML config as there is a pre-existing config - # entry. - assert hass.states.get(TEST_YAML_ENTITY_ID) is None - - -async def test_setup_yaml_old_style_unique_id(hass: HomeAssistantType) -> None: - """Test an already converted YAML style config.""" - # This tests "Possibility 2" from async_setup_platform() - old_unique_id = f"{TEST_HOST}:{TEST_PORT}-0" - - # Add a pre-existing registry entry. - registry = await async_get_registry(hass) - registry.async_get_or_create( - domain=LIGHT_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - suggested_object_id=TEST_YAML_NAME, - ) - - client = create_mock_client() - await _setup_entity_yaml(hass, client=client) - assert client.async_client_disconnect.called - - # The entity should have been created with the same entity_id. - assert hass.states.get(TEST_YAML_ENTITY_ID) is not None - - # The unique_id should have been updated in the registry (rather than the one - # specified above). - assert registry.async_get(TEST_YAML_ENTITY_ID).unique_id == get_hyperion_unique_id( - TEST_SYSINFO_ID, 0, TYPE_HYPERION_LIGHT - ) - assert registry.async_get_entity_id(LIGHT_DOMAIN, DOMAIN, old_unique_id) is None - - # There should be a config entry with the correct server unique_id. - entry = _get_config_entry_from_unique_id(hass, TEST_SYSINFO_ID) - assert entry - assert entry.options == MappingProxyType(TEST_CONFIG_ENTRY_OPTIONS) - - -async def test_setup_yaml_new_style_unique_id_wo_config( - hass: HomeAssistantType, -) -> None: - """Test an a new unique_id without a config entry.""" - # Note: This casde should not happen in the wild, as no released version of Home - # Assistant should this combination, but verify correct behavior for defense in - # depth. - - new_unique_id = get_hyperion_unique_id(TEST_SYSINFO_ID, 0, TYPE_HYPERION_LIGHT) - entity_id_to_preserve = "light.magic_entity" - - # Add a pre-existing registry entry. - registry = await async_get_registry(hass) - registry.async_get_or_create( - domain=LIGHT_DOMAIN, - platform=DOMAIN, - unique_id=new_unique_id, - suggested_object_id=entity_id_to_preserve.split(".")[1], - ) - - client = create_mock_client() - await _setup_entity_yaml(hass, client=client) - assert client.async_client_disconnect.called - - # The entity should have been created with the same entity_id. - assert hass.states.get(entity_id_to_preserve) is not None - - # The unique_id should have been updated in the registry (rather than the one - # specified above). - assert registry.async_get(entity_id_to_preserve).unique_id == new_unique_id - - # There should be a config entry with the correct server unique_id. - entry = _get_config_entry_from_unique_id(hass, TEST_SYSINFO_ID) - assert entry - assert entry.options == MappingProxyType(TEST_CONFIG_ENTRY_OPTIONS) - - -async def test_setup_yaml_no_registry_entity(hass: HomeAssistantType) -> None: - """Test an already converted YAML style config.""" - # This tests "Possibility 3" from async_setup_platform() - - registry = await async_get_registry(hass) - - # Add a pre-existing config entry. - client = create_mock_client() - await _setup_entity_yaml(hass, client=client) - assert client.async_client_disconnect.called - - # The entity should have been created with the same entity_id. - assert hass.states.get(TEST_YAML_ENTITY_ID) is not None - - # The unique_id should have been updated in the registry (rather than the one - # specified above). - assert registry.async_get(TEST_YAML_ENTITY_ID).unique_id == get_hyperion_unique_id( - TEST_SYSINFO_ID, 0, TYPE_HYPERION_LIGHT - ) - - # There should be a config entry with the correct server unique_id. - entry = _get_config_entry_from_unique_id(hass, TEST_SYSINFO_ID) - assert entry - assert entry.options == MappingProxyType(TEST_CONFIG_ENTRY_OPTIONS) - - -async def test_setup_yaml_not_ready(hass: HomeAssistantType) -> None: - """Test the component not being ready.""" - client = create_mock_client() - client.async_client_connect = AsyncMock(return_value=False) - await _setup_entity_yaml(hass, client=client) - assert client.async_client_disconnect.called - assert hass.states.get(TEST_YAML_ENTITY_ID) is None - - async def test_setup_config_entry(hass: HomeAssistantType) -> None: """Test setting up the component via config entries.""" await setup_test_config_entry(hass, hyperion_client=create_mock_client()) diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index dcfba9662bf..34030787e20 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -1,5 +1,4 @@ """Tests for the Hyperion integration.""" -import logging from unittest.mock import AsyncMock, call, patch from hyperion.const import ( @@ -35,7 +34,6 @@ TEST_COMPONENTS = [ {"enabled": True, "name": "LEDDEVICE"}, ] -_LOGGER = logging.getLogger(__name__) TEST_SWITCH_COMPONENT_BASE_ENTITY_ID = "switch.test_instance_1_component" TEST_SWITCH_COMPONENT_ALL_ENTITY_ID = f"{TEST_SWITCH_COMPONENT_BASE_ENTITY_ID}_all" diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index d10df2492d4..41885f0cd26 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -1,17 +1,20 @@ """Test the init file of IFTTT.""" -from unittest.mock import patch - from homeassistant import data_entry_flow from homeassistant.components import ifttt +from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback async def test_config_flow_registers_webhook(hass, aiohttp_client): """Test setting up IFTTT and sending webhook.""" - with patch("homeassistant.util.get_local_ip", return_value="example.com"): - result = await hass.config_entries.flow.async_init( - "ifttt", context={"source": "user"} - ) + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + result = await hass.config_entries.flow.async_init( + "ifttt", context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM, result result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index db22b5c5236..fd43091f457 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -131,6 +131,139 @@ async def test_setup_config_full(hass, mock_client, config_ext, get_write_api): assert get_write_api(mock_client).call_count == 1 +@pytest.mark.parametrize( + "mock_client, config_base, config_ext, expected_client_args", + [ + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + { + "ssl": True, + "verify_ssl": False, + }, + { + "ssl": True, + "verify_ssl": False, + }, + ), + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + { + "ssl": True, + "verify_ssl": True, + }, + { + "ssl": True, + "verify_ssl": True, + }, + ), + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + { + "ssl": True, + "verify_ssl": True, + "ssl_ca_cert": "fake/path/ca.pem", + }, + { + "ssl": True, + "verify_ssl": "fake/path/ca.pem", + }, + ), + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + { + "ssl": True, + "ssl_ca_cert": "fake/path/ca.pem", + }, + { + "ssl": True, + "verify_ssl": "fake/path/ca.pem", + }, + ), + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + { + "ssl": True, + "verify_ssl": False, + "ssl_ca_cert": "fake/path/ca.pem", + }, + { + "ssl": True, + "verify_ssl": False, + }, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + { + "api_version": influxdb.API_VERSION_2, + "verify_ssl": False, + }, + { + "verify_ssl": False, + }, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + { + "api_version": influxdb.API_VERSION_2, + "verify_ssl": True, + }, + { + "verify_ssl": True, + }, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + { + "api_version": influxdb.API_VERSION_2, + "verify_ssl": True, + "ssl_ca_cert": "fake/path/ca.pem", + }, + { + "verify_ssl": True, + "ssl_ca_cert": "fake/path/ca.pem", + }, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + { + "api_version": influxdb.API_VERSION_2, + "verify_ssl": False, + "ssl_ca_cert": "fake/path/ca.pem", + }, + { + "verify_ssl": False, + "ssl_ca_cert": "fake/path/ca.pem", + }, + ), + ], + indirect=["mock_client"], +) +async def test_setup_config_ssl( + hass, mock_client, config_base, config_ext, expected_client_args +): + """Test the setup with various verify_ssl values.""" + config = {"influxdb": config_base.copy()} + config["influxdb"].update(config_ext) + + with patch("os.access", return_value=True): + with patch("os.path.isfile", return_value=True): + assert await async_setup_component(hass, influxdb.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.bus.listen.called + assert EVENT_STATE_CHANGED == hass.bus.listen.call_args_list[0][0][0] + assert expected_client_args.items() <= mock_client.call_args.kwargs.items() + + @pytest.mark.parametrize( "mock_client, config_ext, get_write_api", [ diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 88562678436..c5d4d40e0d5 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -264,7 +264,7 @@ async def test_reload(hass, hass_admin_user): assert "mdi:work_reloaded" == state_2.attributes.get(ATTR_ICON) -async def test_load_person_storage(hass, storage_setup): +async def test_load_from_storage(hass, storage_setup): """Test set up from storage.""" assert await storage_setup() state = hass.states.get(f"{DOMAIN}.from_storage") diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index f1940b1eb39..1b08317ca30 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -369,13 +369,16 @@ async def test_options_add_device_override(hass: HomeAssistantType): CONF_CAT: "05", CONF_SUBCAT: "bb", } - await _options_form(hass, result2["flow_id"], user_input) + result3, _ = await _options_form(hass, result2["flow_id"], user_input) assert len(config_entry.options[CONF_OVERRIDE]) == 2 assert config_entry.options[CONF_OVERRIDE][1][CONF_ADDRESS] == "4D.5E.6F" assert config_entry.options[CONF_OVERRIDE][1][CONF_CAT] == 5 assert config_entry.options[CONF_OVERRIDE][1][CONF_SUBCAT] == 187 + # If result1 eq result2 the changes will not save + assert result["data"] != result3["data"] + async def test_options_remove_device_override(hass: HomeAssistantType): """Test removing a device override.""" @@ -477,6 +480,9 @@ async def test_options_add_x10_device(hass: HomeAssistantType): assert config_entry.options[CONF_X10][1][CONF_PLATFORM] == "binary_sensor" assert config_entry.options[CONF_X10][1][CONF_DIM_STEPS] == 15 + # If result2 eq result3 the changes will not save + assert result2["data"] != result3["data"] + async def test_options_remove_x10_device(hass: HomeAssistantType): """Test removing an X10 device.""" diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py new file mode 100644 index 00000000000..1fce0dbe2a6 --- /dev/null +++ b/tests/components/keenetic_ndms2/__init__.py @@ -0,0 +1,27 @@ +"""Tests for the Keenetic NDMS2 component.""" +from homeassistant.components.keenetic_ndms2 import const +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +MOCK_NAME = "Keenetic Ultra 2030" + +MOCK_DATA = { + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 23, +} + +MOCK_OPTIONS = { + CONF_SCAN_INTERVAL: 15, + const.CONF_CONSIDER_HOME: 150, + const.CONF_TRY_HOTSPOT: False, + const.CONF_INCLUDE_ARP: True, + const.CONF_INCLUDE_ASSOCIATED: True, + const.CONF_INTERFACES: ["Home", "VPS0"], +} diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py new file mode 100644 index 00000000000..aa5369fdc0a --- /dev/null +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test Keenetic NDMS2 setup process.""" + +from unittest.mock import Mock, patch + +from ndms2_client import ConnectionException +from ndms2_client.client import InterfaceInfo, RouterInfo +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import keenetic_ndms2 as keenetic +from homeassistant.components.keenetic_ndms2 import const +from homeassistant.helpers.typing import HomeAssistantType + +from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="connect") +def mock_keenetic_connect(): + """Mock connection routine.""" + with patch("ndms2_client.client.Client.get_router_info") as mock_get_router_info: + mock_get_router_info.return_value = RouterInfo( + name=MOCK_NAME, + fw_version="3.0.4", + fw_channel="stable", + model="mock", + hw_version="0000", + manufacturer="pytest", + vendor="foxel", + region="RU", + ) + yield + + +@pytest.fixture(name="connect_error") +def mock_keenetic_connect_failed(): + """Mock connection routine.""" + with patch( + "ndms2_client.client.Client.get_router_info", + side_effect=ConnectionException("Mocked failure"), + ): + yield + + +async def test_flow_works(hass: HomeAssistantType, connect): + """Test config flow.""" + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == MOCK_NAME + assert result2["data"] == MOCK_DATA + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_works(hass: HomeAssistantType, connect): + """Test config flow.""" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_NAME + assert result["data"] == MOCK_DATA + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + # fake router + hass.data.setdefault(keenetic.DOMAIN, {}) + hass.data[keenetic.DOMAIN][entry.entry_id] = { + keenetic.ROUTER: Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in MOCK_OPTIONS[const.CONF_INTERFACES] + ] + ) + ) + ) + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=MOCK_OPTIONS, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == MOCK_OPTIONS + + +async def test_host_already_configured(hass, connect): + """Test host already configured.""" + + entry = MockConfigEntry( + domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": "user"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_DATA + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_connection_error(hass, connect_error): + """Test error when connection is unsuccessful.""" + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/kmtronic/__init__.py b/tests/components/kmtronic/__init__.py new file mode 100644 index 00000000000..2f089d6495f --- /dev/null +++ b/tests/components/kmtronic/__init__.py @@ -0,0 +1 @@ +"""Tests for the kmtronic integration.""" diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py new file mode 100644 index 00000000000..ebbbf626451 --- /dev/null +++ b/tests/components/kmtronic/test_config_flow.py @@ -0,0 +1,145 @@ +"""Test the kmtronic config flow.""" +from unittest.mock import Mock, patch + +from aiohttp import ClientConnectorError, ClientResponseError + +from homeassistant import config_entries, setup +from homeassistant.components.kmtronic.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED + +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"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + return_value=[Mock()], + ), patch( + "homeassistant.components.kmtronic.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.kmtronic.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", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=ClientResponseError(None, None, status=401), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """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.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=ClientConnectorError(None, Mock()), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_unload_config_entry(hass, aioclient_mock): + """Test entry unloading.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "1.1.1.1", "username": "admin", "password": "admin"}, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0] is config_entry + assert config_entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py new file mode 100644 index 00000000000..5eec3537176 --- /dev/null +++ b/tests/components/kmtronic/test_switch.py @@ -0,0 +1,150 @@ +"""The tests for the KMtronic switch platform.""" +import asyncio +from datetime import timedelta + +from homeassistant.components.kmtronic.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_relay_on_off(hass, aioclient_mock): + """Tests the relay turns on correctly.""" + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Mocks the response for turning a relay1 on + aioclient_mock.get( + "http://1.1.1.1/FF0101", + text="", + ) + + state = hass.states.get("switch.relay1") + assert state.state == "off" + + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "on" + + # Mocks the response for turning a relay1 off + aioclient_mock.get( + "http://1.1.1.1/FF0100", + text="", + ) + + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + +async def test_update(hass, aioclient_mock): + """Tests switch refreshes status periodically.""" + now = dt_util.utcnow() + future = now + timedelta(minutes=10) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="11", + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "on" + + +async def test_config_entry_not_ready(hass, aioclient_mock): + """Tests configuration entry not ready.""" + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + exc=asyncio.TimeoutError(), + ) + + config_entry = MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_failed_update(hass, aioclient_mock): + """Tests coordinator update fails.""" + now = dt_util.utcnow() + future = now + timedelta(minutes=10) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="401 Unauthorized: Password required", + status=401, + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == STATE_UNAVAILABLE + + future += timedelta(minutes=10) + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + exc=asyncio.TimeoutError(), + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 8cf6c635393..0dd75b9c357 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -10,7 +10,6 @@ from . import init_integration from tests.common import ( MockConfigEntry, - assert_lists_same, async_get_device_automations, async_mock_service, mock_device_registry, @@ -69,8 +68,13 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{MP_DOMAIN}.kodi_5678", }, ] + + # Test triggers are either kodi specific triggers or media_player entity triggers triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert_lists_same(triggers, expected_triggers) + for expected_trigger in expected_triggers: + assert expected_trigger in triggers + for trigger in triggers: + assert trigger in expected_triggers or trigger["domain"] == "media_player" async def test_if_fires_on_state_change(hass, calls, kodi_media_player): diff --git a/tests/components/litejet/__init__.py b/tests/components/litejet/__init__.py index 9a01fbe5114..13e2b547cd8 100644 --- a/tests/components/litejet/__init__.py +++ b/tests/components/litejet/__init__.py @@ -1 +1,51 @@ """Tests for the litejet component.""" +from homeassistant.components import scene, switch +from homeassistant.components.litejet import DOMAIN +from homeassistant.const import CONF_PORT + +from tests.common import MockConfigEntry + + +async def async_init_integration( + hass, use_switch=False, use_scene=False +) -> MockConfigEntry: + """Set up the LiteJet integration in Home Assistant.""" + + registry = await hass.helpers.entity_registry.async_get_registry() + + entry_data = {CONF_PORT: "/dev/mock"} + + entry = MockConfigEntry( + domain=DOMAIN, unique_id=entry_data[CONF_PORT], data=entry_data + ) + + if use_switch: + registry.async_get_or_create( + switch.DOMAIN, + DOMAIN, + f"{entry.entry_id}_1", + suggested_object_id="mock_switch_1", + disabled_by=None, + ) + registry.async_get_or_create( + switch.DOMAIN, + DOMAIN, + f"{entry.entry_id}_2", + suggested_object_id="mock_switch_2", + disabled_by=None, + ) + + if use_scene: + registry.async_get_or_create( + scene.DOMAIN, + DOMAIN, + f"{entry.entry_id}_1", + suggested_object_id="mock_scene_1", + disabled_by=None, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 68797f96ccf..00b1eb92190 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -1,2 +1,62 @@ -"""litejet conftest.""" -from tests.components.light.conftest import mock_light_profiles # noqa +"""Fixtures for LiteJet testing.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +import homeassistant.util.dt as dt_util + + +@pytest.fixture +def mock_litejet(): + """Mock LiteJet system.""" + with patch("pylitejet.LiteJet") as mock_pylitejet: + + def get_load_name(number): + return f"Mock Load #{number}" + + def get_scene_name(number): + return f"Mock Scene #{number}" + + def get_switch_name(number): + return f"Mock Switch #{number}" + + mock_lj = mock_pylitejet.return_value + + mock_lj.switch_pressed_callbacks = {} + mock_lj.switch_released_callbacks = {} + mock_lj.load_activated_callbacks = {} + mock_lj.load_deactivated_callbacks = {} + + def on_switch_pressed(number, callback): + mock_lj.switch_pressed_callbacks[number] = callback + + def on_switch_released(number, callback): + mock_lj.switch_released_callbacks[number] = callback + + def on_load_activated(number, callback): + mock_lj.load_activated_callbacks[number] = callback + + def on_load_deactivated(number, callback): + mock_lj.load_deactivated_callbacks[number] = callback + + mock_lj.on_switch_pressed.side_effect = on_switch_pressed + mock_lj.on_switch_released.side_effect = on_switch_released + mock_lj.on_load_activated.side_effect = on_load_activated + mock_lj.on_load_deactivated.side_effect = on_load_deactivated + + mock_lj.loads.return_value = range(1, 3) + mock_lj.get_load_name.side_effect = get_load_name + mock_lj.get_load_level.return_value = 0 + + mock_lj.button_switches.return_value = range(1, 3) + mock_lj.all_switches.return_value = range(1, 6) + mock_lj.get_switch_name.side_effect = get_switch_name + + mock_lj.scenes.return_value = range(1, 3) + mock_lj.get_scene_name.side_effect = get_scene_name + + mock_lj.start_time = dt_util.utcnow() + mock_lj.last_delta = timedelta(0) + + yield mock_lj diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py new file mode 100644 index 00000000000..015ba1c6494 --- /dev/null +++ b/tests/components/litejet/test_config_flow.py @@ -0,0 +1,77 @@ +"""The tests for the litejet component.""" +from unittest.mock import patch + +from serial import SerialException + +from homeassistant.components.litejet.const import DOMAIN +from homeassistant.const import CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_show_config_form(hass): + """Test show configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_create_entry(hass, mock_litejet): + """Test create entry from user input.""" + test_data = {CONF_PORT: "/dev/test"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=test_data + ) + + assert result["type"] == "create_entry" + assert result["title"] == "/dev/test" + assert result["data"] == test_data + + +async def test_flow_entry_already_exists(hass): + """Test user input when a config entry already exists.""" + first_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "/dev/first"}, + ) + first_entry.add_to_hass(hass) + + test_data = {CONF_PORT: "/dev/test"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=test_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_flow_open_failed(hass): + """Test user input when serial port open fails.""" + test_data = {CONF_PORT: "/dev/test"} + + with patch("pylitejet.LiteJet") as mock_pylitejet: + mock_pylitejet.side_effect = SerialException + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=test_data + ) + + assert result["type"] == "form" + assert result["errors"][CONF_PORT] == "open_failed" + + +async def test_import_step(hass): + """Test initializing via import step.""" + test_data = {CONF_PORT: "/dev/imported"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=test_data + ) + + assert result["type"] == "create_entry" + assert result["title"] == test_data[CONF_PORT] + assert result["data"] == test_data diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py index 3861e7a058e..63686452621 100644 --- a/tests/components/litejet/test_init.py +++ b/tests/components/litejet/test_init.py @@ -1,44 +1,30 @@ """The tests for the litejet component.""" -import logging -import unittest - from homeassistant.components import litejet +from homeassistant.components.litejet.const import DOMAIN +from homeassistant.const import CONF_PORT +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant - -_LOGGER = logging.getLogger(__name__) +from . import async_init_integration -class TestLiteJet(unittest.TestCase): - """Test the litejet component.""" +async def test_setup_with_no_config(hass): + """Test that nothing happens.""" + assert await async_setup_component(hass, DOMAIN, {}) is True + assert DOMAIN not in hass.data - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() - self.hass.block_till_done() - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() +async def test_setup_with_config_to_import(hass, mock_litejet): + """Test that import happens.""" + assert ( + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PORT: "/dev/hello"}}) + is True + ) + assert DOMAIN in hass.data - def test_is_ignored_unspecified(self): - """Ensure it is ignored when unspecified.""" - self.hass.data["litejet_config"] = {} - assert not litejet.is_ignored(self.hass, "Test") - def test_is_ignored_empty(self): - """Ensure it is ignored when empty.""" - self.hass.data["litejet_config"] = {litejet.CONF_EXCLUDE_NAMES: []} - assert not litejet.is_ignored(self.hass, "Test") +async def test_unload_entry(hass, mock_litejet): + """Test being able to unload an entry.""" + entry = await async_init_integration(hass, use_switch=True, use_scene=True) - def test_is_ignored_normal(self): - """Test if usually ignored.""" - self.hass.data["litejet_config"] = { - litejet.CONF_EXCLUDE_NAMES: ["Test", "Other One"] - } - assert litejet.is_ignored(self.hass, "Test") - assert not litejet.is_ignored(self.hass, "Other one") - assert not litejet.is_ignored(self.hass, "Other 0ne") - assert litejet.is_ignored(self.hass, "Other One There") - assert litejet.is_ignored(self.hass, "Other One") + assert await litejet.async_unload_entry(hass, entry) + assert DOMAIN not in hass.data diff --git a/tests/components/litejet/test_light.py b/tests/components/litejet/test_light.py index e08bd5c27ac..c455d3a960e 100644 --- a/tests/components/litejet/test_light.py +++ b/tests/components/litejet/test_light.py @@ -1,14 +1,11 @@ """The tests for the litejet component.""" import logging -import unittest -from unittest import mock -from homeassistant import setup -from homeassistant.components import litejet -import homeassistant.components.light as light +from homeassistant.components import light +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON -from tests.common import get_test_home_assistant -from tests.components.light import common +from . import async_init_integration _LOGGER = logging.getLogger(__name__) @@ -18,144 +15,113 @@ ENTITY_OTHER_LIGHT = "light.mock_load_2" ENTITY_OTHER_LIGHT_NUMBER = 2 -class TestLiteJetLight(unittest.TestCase): - """Test the litejet component.""" +async def test_on_brightness(hass, mock_litejet): + """Test turning the light on with brightness.""" + await async_init_integration(hass) - @mock.patch("homeassistant.components.litejet.LiteJet") - def setup_method(self, method, mock_pylitejet): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() + assert hass.states.get(ENTITY_LIGHT).state == "off" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" - self.load_activated_callbacks = {} - self.load_deactivated_callbacks = {} + assert not light.is_on(hass, ENTITY_LIGHT) - def get_load_name(number): - return f"Mock Load #{number}" + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 102}, + blocking=True, + ) + mock_litejet.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 39, 0) - def on_load_activated(number, callback): - self.load_activated_callbacks[number] = callback - def on_load_deactivated(number, callback): - self.load_deactivated_callbacks[number] = callback +async def test_on_off(hass, mock_litejet): + """Test turning the light on and off.""" + await async_init_integration(hass) - self.mock_lj = mock_pylitejet.return_value - self.mock_lj.loads.return_value = range(1, 3) - self.mock_lj.button_switches.return_value = range(0) - self.mock_lj.all_switches.return_value = range(0) - self.mock_lj.scenes.return_value = range(0) - self.mock_lj.get_load_level.return_value = 0 - self.mock_lj.get_load_name.side_effect = get_load_name - self.mock_lj.on_load_activated.side_effect = on_load_activated - self.mock_lj.on_load_deactivated.side_effect = on_load_deactivated + assert hass.states.get(ENTITY_LIGHT).state == "off" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" - assert setup.setup_component( - self.hass, - litejet.DOMAIN, - {"litejet": {"port": "/dev/serial/by-id/mock-litejet"}}, - ) - self.hass.block_till_done() + assert not light.is_on(hass, ENTITY_LIGHT) - self.mock_lj.get_load_level.reset_mock() + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + mock_litejet.activate_load.assert_called_with(ENTITY_LIGHT_NUMBER) - def light(self): - """Test for main light entity.""" - return self.hass.states.get(ENTITY_LIGHT) + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + mock_litejet.deactivate_load.assert_called_with(ENTITY_LIGHT_NUMBER) - def other_light(self): - """Test the other light.""" - return self.hass.states.get(ENTITY_OTHER_LIGHT) - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() +async def test_activated_event(hass, mock_litejet): + """Test handling an event from LiteJet.""" - def test_on_brightness(self): - """Test turning the light on with brightness.""" - assert self.light().state == "off" - assert self.other_light().state == "off" + await async_init_integration(hass) - assert not light.is_on(self.hass, ENTITY_LIGHT) + # Light 1 + mock_litejet.get_load_level.return_value = 99 + mock_litejet.get_load_level.reset_mock() + mock_litejet.load_activated_callbacks[ENTITY_LIGHT_NUMBER]() + await hass.async_block_till_done() - common.turn_on(self.hass, ENTITY_LIGHT, brightness=102) - self.hass.block_till_done() - self.mock_lj.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 39, 0) + mock_litejet.get_load_level.assert_called_once_with(ENTITY_LIGHT_NUMBER) - def test_on_off(self): - """Test turning the light on and off.""" - assert self.light().state == "off" - assert self.other_light().state == "off" + assert light.is_on(hass, ENTITY_LIGHT) + assert not light.is_on(hass, ENTITY_OTHER_LIGHT) + assert hass.states.get(ENTITY_LIGHT).state == "on" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" + assert hass.states.get(ENTITY_LIGHT).attributes.get(ATTR_BRIGHTNESS) == 255 - assert not light.is_on(self.hass, ENTITY_LIGHT) + # Light 2 - common.turn_on(self.hass, ENTITY_LIGHT) - self.hass.block_till_done() - self.mock_lj.activate_load.assert_called_with(ENTITY_LIGHT_NUMBER) + mock_litejet.get_load_level.return_value = 40 + mock_litejet.get_load_level.reset_mock() + mock_litejet.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() + await hass.async_block_till_done() - common.turn_off(self.hass, ENTITY_LIGHT) - self.hass.block_till_done() - self.mock_lj.deactivate_load.assert_called_with(ENTITY_LIGHT_NUMBER) + mock_litejet.get_load_level.assert_called_once_with(ENTITY_OTHER_LIGHT_NUMBER) - def test_activated_event(self): - """Test handling an event from LiteJet.""" - self.mock_lj.get_load_level.return_value = 99 + assert light.is_on(hass, ENTITY_LIGHT) + assert light.is_on(hass, ENTITY_OTHER_LIGHT) + assert hass.states.get(ENTITY_LIGHT).state == "on" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "on" + assert ( + int(hass.states.get(ENTITY_OTHER_LIGHT).attributes.get(ATTR_BRIGHTNESS)) == 103 + ) - # Light 1 - _LOGGER.info(self.load_activated_callbacks[ENTITY_LIGHT_NUMBER]) - self.load_activated_callbacks[ENTITY_LIGHT_NUMBER]() - self.hass.block_till_done() +async def test_deactivated_event(hass, mock_litejet): + """Test handling an event from LiteJet.""" + await async_init_integration(hass) - self.mock_lj.get_load_level.assert_called_once_with(ENTITY_LIGHT_NUMBER) + # Initial state is on. + mock_litejet.get_load_level.return_value = 99 - assert light.is_on(self.hass, ENTITY_LIGHT) - assert not light.is_on(self.hass, ENTITY_OTHER_LIGHT) - assert self.light().state == "on" - assert self.other_light().state == "off" - assert self.light().attributes.get(light.ATTR_BRIGHTNESS) == 255 + mock_litejet.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() + await hass.async_block_till_done() - # Light 2 + assert light.is_on(hass, ENTITY_OTHER_LIGHT) - self.mock_lj.get_load_level.return_value = 40 + # Event indicates it is off now. - self.mock_lj.get_load_level.reset_mock() + mock_litejet.get_load_level.reset_mock() + mock_litejet.get_load_level.return_value = 0 - self.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() - self.hass.block_till_done() + mock_litejet.load_deactivated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() + await hass.async_block_till_done() - self.mock_lj.get_load_level.assert_called_once_with(ENTITY_OTHER_LIGHT_NUMBER) + # (Requesting the level is not strictly needed with a deactivated + # event but the implementation happens to do it. This could be + # changed to an assert_not_called in the future.) + mock_litejet.get_load_level.assert_called_with(ENTITY_OTHER_LIGHT_NUMBER) - assert light.is_on(self.hass, ENTITY_OTHER_LIGHT) - assert light.is_on(self.hass, ENTITY_LIGHT) - assert self.light().state == "on" - assert self.other_light().state == "on" - assert int(self.other_light().attributes[light.ATTR_BRIGHTNESS]) == 103 - - def test_deactivated_event(self): - """Test handling an event from LiteJet.""" - # Initial state is on. - - self.mock_lj.get_load_level.return_value = 99 - - self.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() - self.hass.block_till_done() - - assert light.is_on(self.hass, ENTITY_OTHER_LIGHT) - - # Event indicates it is off now. - - self.mock_lj.get_load_level.reset_mock() - self.mock_lj.get_load_level.return_value = 0 - - self.load_deactivated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]() - self.hass.block_till_done() - - # (Requesting the level is not strictly needed with a deactivated - # event but the implementation happens to do it. This could be - # changed to an assert_not_called in the future.) - self.mock_lj.get_load_level.assert_called_with(ENTITY_OTHER_LIGHT_NUMBER) - - assert not light.is_on(self.hass, ENTITY_OTHER_LIGHT) - assert not light.is_on(self.hass, ENTITY_LIGHT) - assert self.light().state == "off" - assert self.other_light().state == "off" + assert not light.is_on(hass, ENTITY_OTHER_LIGHT) + assert not light.is_on(hass, ENTITY_LIGHT) + assert hass.states.get(ENTITY_LIGHT).state == "off" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index c2297af6d3f..5df26f8c680 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -1,15 +1,8 @@ """The tests for the litejet component.""" -import logging -import unittest -from unittest import mock +from homeassistant.components import scene +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON -from homeassistant import setup -from homeassistant.components import litejet - -from tests.common import get_test_home_assistant -from tests.components.scene import common - -_LOGGER = logging.getLogger(__name__) +from . import async_init_integration ENTITY_SCENE = "scene.mock_scene_1" ENTITY_SCENE_NUMBER = 1 @@ -17,46 +10,31 @@ ENTITY_OTHER_SCENE = "scene.mock_scene_2" ENTITY_OTHER_SCENE_NUMBER = 2 -class TestLiteJetScene(unittest.TestCase): - """Test the litejet component.""" +async def test_disabled_by_default(hass, mock_litejet): + """Test the scene is disabled by default.""" + await async_init_integration(hass) - @mock.patch("homeassistant.components.litejet.LiteJet") - def setup_method(self, method, mock_pylitejet): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() + registry = await hass.helpers.entity_registry.async_get_registry() - def get_scene_name(number): - return f"Mock Scene #{number}" + state = hass.states.get(ENTITY_SCENE) + assert state is None - self.mock_lj = mock_pylitejet.return_value - self.mock_lj.loads.return_value = range(0) - self.mock_lj.button_switches.return_value = range(0) - self.mock_lj.all_switches.return_value = range(0) - self.mock_lj.scenes.return_value = range(1, 3) - self.mock_lj.get_scene_name.side_effect = get_scene_name + entry = registry.async_get(ENTITY_SCENE) + assert entry + assert entry.disabled + assert entry.disabled_by == "integration" - assert setup.setup_component( - self.hass, - litejet.DOMAIN, - {"litejet": {"port": "/dev/serial/by-id/mock-litejet"}}, - ) - self.hass.block_till_done() - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() +async def test_activate(hass, mock_litejet): + """Test activating the scene.""" - def scene(self): - """Get the current scene.""" - return self.hass.states.get(ENTITY_SCENE) + await async_init_integration(hass, use_scene=True) - def other_scene(self): - """Get the other scene.""" - return self.hass.states.get(ENTITY_OTHER_SCENE) + state = hass.states.get(ENTITY_SCENE) + assert state is not None - def test_activate(self): - """Test activating the scene.""" - common.activate(self.hass, ENTITY_SCENE) - self.hass.block_till_done() - self.mock_lj.activate_scene.assert_called_once_with(ENTITY_SCENE_NUMBER) + await hass.services.async_call( + scene.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SCENE}, blocking=True + ) + + mock_litejet.activate_scene.assert_called_once_with(ENTITY_SCENE_NUMBER) diff --git a/tests/components/litejet/test_switch.py b/tests/components/litejet/test_switch.py index 2f897045c92..dfcb9801093 100644 --- a/tests/components/litejet/test_switch.py +++ b/tests/components/litejet/test_switch.py @@ -1,14 +1,10 @@ """The tests for the litejet component.""" import logging -import unittest -from unittest import mock -from homeassistant import setup -from homeassistant.components import litejet -import homeassistant.components.switch as switch +from homeassistant.components import switch +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON -from tests.common import get_test_home_assistant -from tests.components.switch import common +from . import async_init_integration _LOGGER = logging.getLogger(__name__) @@ -18,117 +14,67 @@ ENTITY_OTHER_SWITCH = "switch.mock_switch_2" ENTITY_OTHER_SWITCH_NUMBER = 2 -class TestLiteJetSwitch(unittest.TestCase): - """Test the litejet component.""" +async def test_on_off(hass, mock_litejet): + """Test turning the switch on and off.""" - @mock.patch("homeassistant.components.litejet.LiteJet") - def setup_method(self, method, mock_pylitejet): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() + await async_init_integration(hass, use_switch=True) - self.switch_pressed_callbacks = {} - self.switch_released_callbacks = {} + assert hass.states.get(ENTITY_SWITCH).state == "off" + assert hass.states.get(ENTITY_OTHER_SWITCH).state == "off" - def get_switch_name(number): - return f"Mock Switch #{number}" + assert not switch.is_on(hass, ENTITY_SWITCH) - def on_switch_pressed(number, callback): - self.switch_pressed_callbacks[number] = callback + await hass.services.async_call( + switch.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True + ) + mock_litejet.press_switch.assert_called_with(ENTITY_SWITCH_NUMBER) - def on_switch_released(number, callback): - self.switch_released_callbacks[number] = callback + await hass.services.async_call( + switch.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True + ) + mock_litejet.release_switch.assert_called_with(ENTITY_SWITCH_NUMBER) - self.mock_lj = mock_pylitejet.return_value - self.mock_lj.loads.return_value = range(0) - self.mock_lj.button_switches.return_value = range(1, 3) - self.mock_lj.all_switches.return_value = range(1, 6) - self.mock_lj.scenes.return_value = range(0) - self.mock_lj.get_switch_name.side_effect = get_switch_name - self.mock_lj.on_switch_pressed.side_effect = on_switch_pressed - self.mock_lj.on_switch_released.side_effect = on_switch_released - config = {"litejet": {"port": "/dev/serial/by-id/mock-litejet"}} - if method == self.test_include_switches_False: - config["litejet"]["include_switches"] = False - elif method != self.test_include_switches_unspecified: - config["litejet"]["include_switches"] = True +async def test_pressed_event(hass, mock_litejet): + """Test handling an event from LiteJet.""" - assert setup.setup_component(self.hass, litejet.DOMAIN, config) - self.hass.block_till_done() + await async_init_integration(hass, use_switch=True) - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() + # Switch 1 + mock_litejet.switch_pressed_callbacks[ENTITY_SWITCH_NUMBER]() + await hass.async_block_till_done() - def switch(self): - """Return the switch state.""" - return self.hass.states.get(ENTITY_SWITCH) + assert switch.is_on(hass, ENTITY_SWITCH) + assert not switch.is_on(hass, ENTITY_OTHER_SWITCH) + assert hass.states.get(ENTITY_SWITCH).state == "on" + assert hass.states.get(ENTITY_OTHER_SWITCH).state == "off" - def other_switch(self): - """Return the other switch state.""" - return self.hass.states.get(ENTITY_OTHER_SWITCH) + # Switch 2 + mock_litejet.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() + await hass.async_block_till_done() - def test_include_switches_unspecified(self): - """Test that switches are ignored by default.""" - self.mock_lj.button_switches.assert_not_called() - self.mock_lj.all_switches.assert_not_called() + assert switch.is_on(hass, ENTITY_OTHER_SWITCH) + assert switch.is_on(hass, ENTITY_SWITCH) + assert hass.states.get(ENTITY_SWITCH).state == "on" + assert hass.states.get(ENTITY_OTHER_SWITCH).state == "on" - def test_include_switches_False(self): - """Test that switches can be explicitly ignored.""" - self.mock_lj.button_switches.assert_not_called() - self.mock_lj.all_switches.assert_not_called() - def test_on_off(self): - """Test turning the switch on and off.""" - assert self.switch().state == "off" - assert self.other_switch().state == "off" +async def test_released_event(hass, mock_litejet): + """Test handling an event from LiteJet.""" - assert not switch.is_on(self.hass, ENTITY_SWITCH) + await async_init_integration(hass, use_switch=True) - common.turn_on(self.hass, ENTITY_SWITCH) - self.hass.block_till_done() - self.mock_lj.press_switch.assert_called_with(ENTITY_SWITCH_NUMBER) + # Initial state is on. + mock_litejet.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() + await hass.async_block_till_done() - common.turn_off(self.hass, ENTITY_SWITCH) - self.hass.block_till_done() - self.mock_lj.release_switch.assert_called_with(ENTITY_SWITCH_NUMBER) + assert switch.is_on(hass, ENTITY_OTHER_SWITCH) - def test_pressed_event(self): - """Test handling an event from LiteJet.""" - # Switch 1 - _LOGGER.info(self.switch_pressed_callbacks[ENTITY_SWITCH_NUMBER]) - self.switch_pressed_callbacks[ENTITY_SWITCH_NUMBER]() - self.hass.block_till_done() + # Event indicates it is off now. + mock_litejet.switch_released_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() + await hass.async_block_till_done() - assert switch.is_on(self.hass, ENTITY_SWITCH) - assert not switch.is_on(self.hass, ENTITY_OTHER_SWITCH) - assert self.switch().state == "on" - assert self.other_switch().state == "off" - - # Switch 2 - self.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() - self.hass.block_till_done() - - assert switch.is_on(self.hass, ENTITY_OTHER_SWITCH) - assert switch.is_on(self.hass, ENTITY_SWITCH) - assert self.other_switch().state == "on" - assert self.switch().state == "on" - - def test_released_event(self): - """Test handling an event from LiteJet.""" - # Initial state is on. - self.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() - self.hass.block_till_done() - - assert switch.is_on(self.hass, ENTITY_OTHER_SWITCH) - - # Event indicates it is off now. - - self.switch_released_callbacks[ENTITY_OTHER_SWITCH_NUMBER]() - self.hass.block_till_done() - - assert not switch.is_on(self.hass, ENTITY_OTHER_SWITCH) - assert not switch.is_on(self.hass, ENTITY_SWITCH) - assert self.other_switch().state == "off" - assert self.switch().state == "off" + assert not switch.is_on(hass, ENTITY_OTHER_SWITCH) + assert not switch.is_on(hass, ENTITY_SWITCH) + assert hass.states.get(ENTITY_SWITCH).state == "off" + assert hass.states.get(ENTITY_OTHER_SWITCH).state == "off" diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 3cbbd474b88..216da9b54ef 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -2,14 +2,16 @@ from datetime import timedelta import logging from unittest import mock +from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components import litejet import homeassistant.components.automation as automation import homeassistant.util.dt as dt_util +from . import async_init_integration + from tests.common import async_fire_time_changed, async_mock_service from tests.components.blueprint.conftest import stub_blueprint_populate # noqa @@ -27,88 +29,51 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -def get_switch_name(number): - """Get a mock switch name.""" - return f"Mock Switch #{number}" - - -@pytest.fixture -def mock_lj(hass): - """Initialize components.""" - with mock.patch("homeassistant.components.litejet.LiteJet") as mock_pylitejet: - mock_lj = mock_pylitejet.return_value - - mock_lj.switch_pressed_callbacks = {} - mock_lj.switch_released_callbacks = {} - - def on_switch_pressed(number, callback): - mock_lj.switch_pressed_callbacks[number] = callback - - def on_switch_released(number, callback): - mock_lj.switch_released_callbacks[number] = callback - - mock_lj.loads.return_value = range(0) - mock_lj.button_switches.return_value = range(1, 3) - mock_lj.all_switches.return_value = range(1, 6) - mock_lj.scenes.return_value = range(0) - mock_lj.get_switch_name.side_effect = get_switch_name - mock_lj.on_switch_pressed.side_effect = on_switch_pressed - mock_lj.on_switch_released.side_effect = on_switch_released - - config = {"litejet": {"port": "/dev/serial/by-id/mock-litejet"}} - assert hass.loop.run_until_complete( - setup.async_setup_component(hass, litejet.DOMAIN, config) - ) - - mock_lj.start_time = dt_util.utcnow() - mock_lj.last_delta = timedelta(0) - return mock_lj - - -async def simulate_press(hass, mock_lj, number): +async def simulate_press(hass, mock_litejet, number): """Test to simulate a press.""" _LOGGER.info("*** simulate press of %d", number) - callback = mock_lj.switch_pressed_callbacks.get(number) + callback = mock_litejet.switch_pressed_callbacks.get(number) with mock.patch( "homeassistant.helpers.condition.dt_util.utcnow", - return_value=mock_lj.start_time + mock_lj.last_delta, + return_value=mock_litejet.start_time + mock_litejet.last_delta, ): if callback is not None: await hass.async_add_executor_job(callback) await hass.async_block_till_done() -async def simulate_release(hass, mock_lj, number): +async def simulate_release(hass, mock_litejet, number): """Test to simulate releasing.""" _LOGGER.info("*** simulate release of %d", number) - callback = mock_lj.switch_released_callbacks.get(number) + callback = mock_litejet.switch_released_callbacks.get(number) with mock.patch( "homeassistant.helpers.condition.dt_util.utcnow", - return_value=mock_lj.start_time + mock_lj.last_delta, + return_value=mock_litejet.start_time + mock_litejet.last_delta, ): if callback is not None: await hass.async_add_executor_job(callback) await hass.async_block_till_done() -async def simulate_time(hass, mock_lj, delta): +async def simulate_time(hass, mock_litejet, delta): """Test to simulate time.""" _LOGGER.info( - "*** simulate time change by %s: %s", delta, mock_lj.start_time + delta + "*** simulate time change by %s: %s", delta, mock_litejet.start_time + delta ) - mock_lj.last_delta = delta + mock_litejet.last_delta = delta with mock.patch( "homeassistant.helpers.condition.dt_util.utcnow", - return_value=mock_lj.start_time + delta, + return_value=mock_litejet.start_time + delta, ): _LOGGER.info("now=%s", dt_util.utcnow()) - async_fire_time_changed(hass, mock_lj.start_time + delta) + async_fire_time_changed(hass, mock_litejet.start_time + delta) await hass.async_block_till_done() _LOGGER.info("done with now=%s", dt_util.utcnow()) async def setup_automation(hass, trigger): """Test setting up the automation.""" + await async_init_integration(hass, use_switch=True) assert await setup.async_setup_component( hass, automation.DOMAIN, @@ -125,19 +90,19 @@ async def setup_automation(hass, trigger): await hass.async_block_till_done() -async def test_simple(hass, calls, mock_lj): +async def test_simple(hass, calls, mock_litejet): """Test the simplest form of a LiteJet trigger.""" await setup_automation( hass, {"platform": "litejet", "number": ENTITY_OTHER_SWITCH_NUMBER} ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 1 -async def test_held_more_than_short(hass, calls, mock_lj): +async def test_held_more_than_short(hass, calls, mock_litejet): """Test a too short hold.""" await setup_automation( hass, @@ -148,13 +113,13 @@ async def test_held_more_than_short(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.1)) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 -async def test_held_more_than_long(hass, calls, mock_lj): +async def test_held_more_than_long(hass, calls, mock_litejet): """Test a hold that is long enough.""" await setup_automation( hass, @@ -165,15 +130,15 @@ async def test_held_more_than_long(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 - await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.3)) assert len(calls) == 1 - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 1 -async def test_held_less_than_short(hass, calls, mock_lj): +async def test_held_less_than_short(hass, calls, mock_litejet): """Test a hold that is short enough.""" await setup_automation( hass, @@ -184,14 +149,14 @@ async def test_held_less_than_short(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.1)) assert len(calls) == 0 - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 1 -async def test_held_less_than_long(hass, calls, mock_lj): +async def test_held_less_than_long(hass, calls, mock_litejet): """Test a hold that is too long.""" await setup_automation( hass, @@ -202,15 +167,15 @@ async def test_held_less_than_long(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 - await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.3)) assert len(calls) == 0 - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 -async def test_held_in_range_short(hass, calls, mock_lj): +async def test_held_in_range_short(hass, calls, mock_litejet): """Test an in-range trigger with a too short hold.""" await setup_automation( hass, @@ -222,13 +187,13 @@ async def test_held_in_range_short(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - await simulate_time(hass, mock_lj, timedelta(seconds=0.05)) - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.05)) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 -async def test_held_in_range_just_right(hass, calls, mock_lj): +async def test_held_in_range_just_right(hass, calls, mock_litejet): """Test an in-range trigger with a just right hold.""" await setup_automation( hass, @@ -240,15 +205,15 @@ async def test_held_in_range_just_right(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 - await simulate_time(hass, mock_lj, timedelta(seconds=0.2)) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.2)) assert len(calls) == 0 - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 1 -async def test_held_in_range_long(hass, calls, mock_lj): +async def test_held_in_range_long(hass, calls, mock_litejet): """Test an in-range trigger with a too long hold.""" await setup_automation( hass, @@ -260,9 +225,50 @@ async def test_held_in_range_long(hass, calls, mock_lj): }, ) - await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 - await simulate_time(hass, mock_lj, timedelta(seconds=0.4)) + await simulate_time(hass, mock_litejet, timedelta(seconds=0.4)) assert len(calls) == 0 - await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) assert len(calls) == 0 + + +async def test_reload(hass, calls, mock_litejet): + """Test reloading automation.""" + await setup_automation( + hass, + { + "platform": "litejet", + "number": ENTITY_OTHER_SWITCH_NUMBER, + "held_more_than": {"milliseconds": "100"}, + "held_less_than": {"milliseconds": "300"}, + }, + ) + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={ + "automation": { + "trigger": { + "platform": "litejet", + "number": ENTITY_OTHER_SWITCH_NUMBER, + "held_more_than": {"milliseconds": "1000"}, + }, + "action": {"service": "test.automation"}, + } + }, + ): + await hass.services.async_call( + "automation", + "reload", + blocking=True, + ) + await hass.async_block_till_done() + + await simulate_press(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_litejet, timedelta(seconds=0.5)) + assert len(calls) == 0 + await simulate_time(hass, mock_litejet, timedelta(seconds=1.25)) + assert len(calls) == 1 diff --git a/tests/components/litterrobot/__init__.py b/tests/components/litterrobot/__init__.py new file mode 100644 index 00000000000..a7267365100 --- /dev/null +++ b/tests/components/litterrobot/__init__.py @@ -0,0 +1 @@ +"""Tests for the Litter-Robot Component.""" diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py new file mode 100644 index 00000000000..ed893a3a756 --- /dev/null +++ b/tests/components/litterrobot/common.py @@ -0,0 +1,24 @@ +"""Common utils for Litter-Robot tests.""" +from homeassistant.components.litterrobot import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +BASE_PATH = "homeassistant.components.litterrobot" +CONFIG = {DOMAIN: {CONF_USERNAME: "user@example.com", CONF_PASSWORD: "password"}} + +ROBOT_NAME = "Test" +ROBOT_SERIAL = "LR3C012345" +ROBOT_DATA = { + "powerStatus": "AC", + "lastSeen": "2021-02-01T15:30:00.000000", + "cleanCycleWaitTimeMinutes": "7", + "unitStatus": "RDY", + "litterRobotNickname": ROBOT_NAME, + "cycleCount": "15", + "panelLockActive": "0", + "cyclesAfterDrawerFull": "0", + "litterRobotSerial": ROBOT_SERIAL, + "cycleCapacity": "30", + "litterRobotId": "a0123b4567cd8e", + "nightLightActive": "1", + "sleepModeActive": "112:50:19", +} diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py new file mode 100644 index 00000000000..dae183b4cf6 --- /dev/null +++ b/tests/components/litterrobot/conftest.py @@ -0,0 +1,55 @@ +"""Configure pytest for Litter-Robot tests.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from pylitterbot import Robot +import pytest + +from homeassistant.components import litterrobot +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .common import CONFIG, ROBOT_DATA + +from tests.common import MockConfigEntry + + +def create_mock_robot(hass): + """Create a mock Litter-Robot device.""" + robot = Robot(data=ROBOT_DATA) + robot.start_cleaning = AsyncMock() + robot.set_power_status = AsyncMock() + robot.reset_waste_drawer = AsyncMock() + robot.set_sleep_mode = AsyncMock() + robot.set_night_light = AsyncMock() + robot.set_panel_lockout = AsyncMock() + return robot + + +@pytest.fixture() +def mock_hub(hass): + """Mock a Litter-Robot hub.""" + hub = MagicMock( + hass=hass, + account=MagicMock(), + logged_in=True, + coordinator=MagicMock(spec=DataUpdateCoordinator), + spec=litterrobot.LitterRobotHub, + ) + hub.coordinator.last_update_success = True + hub.account.robots = [create_mock_robot(hass)] + return hub + + +async def setup_hub(hass, mock_hub, platform_domain): + """Load a Litter-Robot platform with the provided hub.""" + entry = MockConfigEntry( + domain=litterrobot.DOMAIN, + data=CONFIG[litterrobot.DOMAIN], + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.litterrobot.LitterRobotHub", + return_value=mock_hub, + ): + await hass.config_entries.async_forward_entry_setup(entry, platform_domain) + await hass.async_block_till_done() diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py new file mode 100644 index 00000000000..fd88595d37e --- /dev/null +++ b/tests/components/litterrobot/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the Litter-Robot config flow.""" +from unittest.mock import patch + +from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException + +from homeassistant import config_entries, setup + +from .common import CONF_USERNAME, CONFIG, DOMAIN + + +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"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + return_value=True, + ), patch( + "homeassistant.components.litterrobot.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.litterrobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG[DOMAIN] + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result2["data"] == CONFIG[DOMAIN] + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + side_effect=LitterRobotLoginException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG[DOMAIN] + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + side_effect=LitterRobotException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG[DOMAIN] + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG[DOMAIN] + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py new file mode 100644 index 00000000000..1d0ed075cc7 --- /dev/null +++ b/tests/components/litterrobot/test_init.py @@ -0,0 +1,20 @@ +"""Test Litter-Robot setup process.""" +from homeassistant.components import litterrobot +from homeassistant.setup import async_setup_component + +from .common import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry( + domain=litterrobot.DOMAIN, + data=CONFIG[litterrobot.DOMAIN], + ) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, litterrobot.DOMAIN, {}) is True + assert await litterrobot.async_unload_entry(hass, entry) + assert hass.data[litterrobot.DOMAIN] == {} diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py new file mode 100644 index 00000000000..2421489e237 --- /dev/null +++ b/tests/components/litterrobot/test_sensor.py @@ -0,0 +1,20 @@ +"""Test the Litter-Robot sensor entity.""" +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import PERCENTAGE + +from .conftest import setup_hub + +ENTITY_ID = "sensor.test_waste_drawer" + + +async def test_sensor(hass, mock_hub): + """Tests the sensor entity was set up.""" + await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + + sensor = hass.states.get(ENTITY_ID) + assert sensor + assert sensor.state == "50" + assert sensor.attributes["cycle_count"] == 15 + assert sensor.attributes["cycle_capacity"] == 30 + assert sensor.attributes["cycles_after_drawer_full"] == 0 + assert sensor.attributes["unit_of_measurement"] == PERCENTAGE diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py new file mode 100644 index 00000000000..c7f85db7412 --- /dev/null +++ b/tests/components/litterrobot/test_switch.py @@ -0,0 +1,59 @@ +"""Test the Litter-Robot switch entity.""" +from datetime import timedelta + +import pytest + +from homeassistant.components.litterrobot.hub import REFRESH_WAIT_TIME +from homeassistant.components.switch import ( + DOMAIN as PLATFORM_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.util.dt import utcnow + +from .conftest import setup_hub + +from tests.common import async_fire_time_changed + +NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode" +PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout" + + +async def test_switch(hass, mock_hub): + """Tests the switch entity was set up.""" + await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + + switch = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) + assert switch + assert switch.state == STATE_ON + + +@pytest.mark.parametrize( + "entity_id,robot_command", + [ + (NIGHT_LIGHT_MODE_ENTITY_ID, "set_night_light"), + (PANEL_LOCKOUT_ENTITY_ID, "set_panel_lockout"), + ], +) +async def test_on_off_commands(hass, mock_hub, entity_id, robot_command): + """Test sending commands to the switch.""" + await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + + switch = hass.states.get(entity_id) + assert switch + + data = {ATTR_ENTITY_ID: entity_id} + + count = 0 + for service in [SERVICE_TURN_ON, SERVICE_TURN_OFF]: + count += 1 + await hass.services.async_call( + PLATFORM_DOMAIN, + service, + data, + blocking=True, + ) + future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME) + async_fire_time_changed(hass, future) + assert getattr(mock_hub.account.robots[0], robot_command).call_count == count diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py new file mode 100644 index 00000000000..03e63b472b6 --- /dev/null +++ b/tests/components/litterrobot/test_vacuum.py @@ -0,0 +1,77 @@ +"""Test the Litter-Robot vacuum entity.""" +from datetime import timedelta + +import pytest + +from homeassistant.components.litterrobot.hub import REFRESH_WAIT_TIME +from homeassistant.components.vacuum import ( + ATTR_PARAMS, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SEND_COMMAND, + SERVICE_START, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_DOCKED, +) +from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID +from homeassistant.util.dt import utcnow + +from .conftest import setup_hub + +from tests.common import async_fire_time_changed + +ENTITY_ID = "vacuum.test_litter_box" + + +async def test_vacuum(hass, mock_hub): + """Tests the vacuum entity was set up.""" + await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + assert vacuum.state == STATE_DOCKED + assert vacuum.attributes["is_sleeping"] is False + + +@pytest.mark.parametrize( + "service,command,extra", + [ + (SERVICE_START, "start_cleaning", None), + (SERVICE_TURN_OFF, "set_power_status", None), + (SERVICE_TURN_ON, "set_power_status", None), + ( + SERVICE_SEND_COMMAND, + "reset_waste_drawer", + {ATTR_COMMAND: "reset_waste_drawer"}, + ), + ( + SERVICE_SEND_COMMAND, + "set_sleep_mode", + { + ATTR_COMMAND: "set_sleep_mode", + ATTR_PARAMS: {"enabled": True, "sleep_time": "22:30"}, + }, + ), + ], +) +async def test_commands(hass, mock_hub, service, command, extra): + """Test sending commands to the vacuum.""" + await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum is not None + assert vacuum.state == STATE_DOCKED + + data = {ATTR_ENTITY_ID: ENTITY_ID} + if extra: + data.update(extra) + + await hass.services.async_call( + PLATFORM_DOMAIN, + service, + data, + blocking=True, + ) + future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME) + async_fire_time_changed(hass, future) + getattr(mock_hub.account.robots[0], command).assert_called_once() diff --git a/tests/components/lock/test_significant_change.py b/tests/components/lock/test_significant_change.py new file mode 100644 index 00000000000..a9ffbc0d1c4 --- /dev/null +++ b/tests/components/lock/test_significant_change.py @@ -0,0 +1,23 @@ +"""Test the Lock significant change platform.""" +from homeassistant.components.lock.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change(): + """Detect Lock significant changes.""" + old_attrs = {"attr_1": "a"} + new_attrs = {"attr_1": "b"} + + assert ( + async_check_significant_change(None, "locked", old_attrs, "locked", old_attrs) + is False + ) + assert ( + async_check_significant_change(None, "locked", old_attrs, "locked", new_attrs) + is False + ) + assert ( + async_check_significant_change(None, "locked", old_attrs, "unlocked", old_attrs) + is True + ) diff --git a/tests/components/lyric/__init__.py b/tests/components/lyric/__init__.py new file mode 100644 index 00000000000..794c6bf1ba0 --- /dev/null +++ b/tests/components/lyric/__init__.py @@ -0,0 +1 @@ +"""Tests for the Honeywell Lyric integration.""" diff --git a/tests/components/lyric/test_config_flow.py b/tests/components/lyric/test_config_flow.py new file mode 100644 index 00000000000..78fd9013466 --- /dev/null +++ b/tests/components/lyric/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Honeywell Lyric config flow.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.http import CONF_BASE_URL, DOMAIN as DOMAIN_HTTP +from homeassistant.components.lyric import config_flow +from homeassistant.components.lyric.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture() +async def mock_impl(hass): + """Mock implementation.""" + await setup.async_setup_component(hass, "http", {}) + + impl = config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + CLIENT_ID, + CLIENT_SECRET, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ) + config_flow.OAuth2FlowHandler.async_register_implementation(hass, impl) + return impl + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "missing_configuration" + + +async def test_full_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + DOMAIN_HTTP: {CONF_BASE_URL: "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.lyric.api.ConfigEntryLyricClient"): + with patch( + "homeassistant.components.lyric.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["data"]["auth_implementation"] == DOMAIN + + result["data"]["token"].pop("expires_at") + assert result["data"]["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + + assert DOMAIN in hass.config.components + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == config_entries.ENTRY_STATE_LOADED + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_abort_if_authorization_timeout( + hass, mock_impl, current_request_with_host +): + """Check Somfy authorization timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = config_flow.OAuth2FlowHandler() + flow.hass = hass + + with patch.object( + mock_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError + ): + result = await flow.async_step_user() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "authorize_url_timeout" diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py new file mode 100644 index 00000000000..f7a267a5110 --- /dev/null +++ b/tests/components/mazda/__init__.py @@ -0,0 +1,53 @@ +"""Tests for the Mazda Connected Services integration.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pymazda import Client as MazdaAPI + +from homeassistant.components.mazda.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from tests.common import MockConfigEntry, load_fixture + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "example@example.com", + CONF_PASSWORD: "password", + CONF_REGION: "MNAO", +} + + +async def init_integration(hass: HomeAssistant, use_nickname=True) -> MockConfigEntry: + """Set up the Mazda Connected Services integration in Home Assistant.""" + get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) + if not use_nickname: + get_vehicles_fixture[0].pop("nickname") + + get_vehicle_status_fixture = json.loads( + load_fixture("mazda/get_vehicle_status.json") + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) + config_entry.add_to_hass(hass) + + client_mock = MagicMock( + MazdaAPI( + FIXTURE_USER_INPUT[CONF_EMAIL], + FIXTURE_USER_INPUT[CONF_PASSWORD], + FIXTURE_USER_INPUT[CONF_REGION], + aiohttp_client.async_get_clientsession(hass), + ) + ) + client_mock.get_vehicles = AsyncMock(return_value=get_vehicles_fixture) + client_mock.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture) + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI", + return_value=client_mock, + ), patch("homeassistant.components.mazda.MazdaAPI", return_value=client_mock): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/mazda/test_config_flow.py b/tests/components/mazda/test_config_flow.py new file mode 100644 index 00000000000..fbdd74bfdfa --- /dev/null +++ b/tests/components/mazda/test_config_flow.py @@ -0,0 +1,310 @@ +"""Test the Mazda Connected Services config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.mazda.config_flow import ( + MazdaAccountLockedException, + MazdaAuthenticationException, +) +from homeassistant.components.mazda.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "example@example.com", + CONF_PASSWORD: "password", + CONF_REGION: "MNAO", +} +FIXTURE_USER_INPUT_REAUTH = { + CONF_EMAIL: "example@example.com", + CONF_PASSWORD: "password_fixed", + CONF_REGION: "MNAO", +} + + +async def test_form(hass): + """Test the entire flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + return_value=True, + ), patch( + "homeassistant.components.mazda.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mazda.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=MazdaAuthenticationException("Failed to authenticate"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_account_locked(hass: HomeAssistant) -> None: + """Test we handle account locked error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=MazdaAccountLockedException("Account locked"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "account_locked"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=MazdaAuthenticationException("Failed to authenticate"), + ): + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], + data=FIXTURE_USER_INPUT, + ) + mock_config.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth", "unique_id": FIXTURE_USER_INPUT[CONF_EMAIL]}, + data=FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on authorization error.""" + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=MazdaAuthenticationException("Failed to authenticate"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_account_locked(hass: HomeAssistant) -> None: + """Test we show user form on account_locked error.""" + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=MazdaAccountLockedException("Account locked"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "account_locked"} + + +async def test_reauth_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_unknown_error(hass: HomeAssistant) -> None: + """Test we show user form on unknown error.""" + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth_unique_id_not_found(hass: HomeAssistant) -> None: + """Test we show user form when unique id not found during reauth.""" + with patch( + "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + # Change the unique_id of the flow in order to cause a mismatch + flows = hass.config_entries.flow.async_progress() + flows[0]["context"]["unique_id"] = "example2@example.com" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT_REAUTH, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py new file mode 100644 index 00000000000..d0352682f53 --- /dev/null +++ b/tests/components/mazda/test_init.py @@ -0,0 +1,100 @@ +"""Tests for the Mazda Connected Services integration.""" +from unittest.mock import patch + +from pymazda import MazdaAuthenticationException, MazdaException + +from homeassistant.components.mazda.const import DATA_COORDINATOR, DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.mazda import init_integration + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "example@example.com", + CONF_PASSWORD: "password", + CONF_REGION: "MNAO", +} + + +async def test_config_entry_not_ready(hass: HomeAssistant) -> None: + """Test the Mazda configuration entry not ready.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.mazda.MazdaAPI.validate_credentials", + side_effect=MazdaException("Unknown error"), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_init_auth_failure(hass: HomeAssistant): + """Test auth failure during setup.""" + with patch( + "homeassistant.components.mazda.MazdaAPI.validate_credentials", + side_effect=MazdaAuthenticationException("Login failed"), + ): + config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ENTRY_STATE_SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth" + + +async def test_update_auth_failure(hass: HomeAssistant): + """Test auth failure during data update.""" + with patch( + "homeassistant.components.mazda.MazdaAPI.validate_credentials", + return_value=True, + ), patch("homeassistant.components.mazda.MazdaAPI.get_vehicles", return_value={}): + config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ENTRY_STATE_LOADED + + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + with patch( + "homeassistant.components.mazda.MazdaAPI.validate_credentials", + side_effect=MazdaAuthenticationException("Login failed"), + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_vehicles", + side_effect=MazdaAuthenticationException("Login failed"), + ): + await coordinator.async_refresh() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth" + + +async def test_unload_config_entry(hass: HomeAssistant) -> None: + """Test the Mazda configuration entry unloading.""" + entry = await init_integration(hass) + assert hass.data[DOMAIN] + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py new file mode 100644 index 00000000000..1cb9f7ac4b7 --- /dev/null +++ b/tests/components/mazda/test_sensor.py @@ -0,0 +1,160 @@ +"""The sensor tests for the Mazda Connected Services integration.""" + +from homeassistant.components.mazda.const import DOMAIN +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + LENGTH_KILOMETERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_PSI, +) +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from tests.components.mazda import init_integration + + +async def test_device_nickname(hass): + """Test creation of the device when vehicle has a nickname.""" + await init_integration(hass, use_nickname=True) + + device_registry = await hass.helpers.device_registry.async_get_registry() + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + + assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD" + assert reg_device.manufacturer == "Mazda" + assert reg_device.name == "My Mazda3" + + +async def test_device_no_nickname(hass): + """Test creation of the device when vehicle has no nickname.""" + await init_integration(hass, use_nickname=False) + + device_registry = await hass.helpers.device_registry.async_get_registry() + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + + assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD" + assert reg_device.manufacturer == "Mazda" + assert reg_device.name == "2021 MAZDA3 2.5 S SE AWD" + + +async def test_sensors(hass): + """Test creation of the sensors.""" + await init_integration(hass) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Fuel Remaining Percentage + state = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "My Mazda3 Fuel Remaining Percentage" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.state == "87.0" + entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage") + assert entry + assert entry.unique_id == "JM000000000000000_fuel_remaining_percentage" + + # Fuel Distance Remaining + state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Fuel Distance Remaining" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS + assert state.state == "381" + entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining") + assert entry + assert entry.unique_id == "JM000000000000000_fuel_distance_remaining" + + # Odometer + state = hass.states.get("sensor.my_mazda3_odometer") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Odometer" + assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS + assert state.state == "2796" + entry = entity_registry.async_get("sensor.my_mazda3_odometer") + assert entry + assert entry.unique_id == "JM000000000000000_odometer" + + # Front Left Tire Pressure + state = hass.states.get("sensor.my_mazda3_front_left_tire_pressure") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Front Left Tire Pressure" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.state == "35" + entry = entity_registry.async_get("sensor.my_mazda3_front_left_tire_pressure") + assert entry + assert entry.unique_id == "JM000000000000000_front_left_tire_pressure" + + # Front Right Tire Pressure + state = hass.states.get("sensor.my_mazda3_front_right_tire_pressure") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "My Mazda3 Front Right Tire Pressure" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.state == "35" + entry = entity_registry.async_get("sensor.my_mazda3_front_right_tire_pressure") + assert entry + assert entry.unique_id == "JM000000000000000_front_right_tire_pressure" + + # Rear Left Tire Pressure + state = hass.states.get("sensor.my_mazda3_rear_left_tire_pressure") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear Left Tire Pressure" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.state == "33" + entry = entity_registry.async_get("sensor.my_mazda3_rear_left_tire_pressure") + assert entry + assert entry.unique_id == "JM000000000000000_rear_left_tire_pressure" + + # Rear Right Tire Pressure + state = hass.states.get("sensor.my_mazda3_rear_right_tire_pressure") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear Right Tire Pressure" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.state == "33" + entry = entity_registry.async_get("sensor.my_mazda3_rear_right_tire_pressure") + assert entry + assert entry.unique_id == "JM000000000000000_rear_right_tire_pressure" + + +async def test_sensors_imperial_units(hass): + """Test that the sensors work properly with imperial units.""" + hass.config.units = IMPERIAL_SYSTEM + + await init_integration(hass) + + # Fuel Distance Remaining + state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILES + assert state.state == "237" + + # Odometer + state = hass.states.get("sensor.my_mazda3_odometer") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILES + assert state.state == "1737" diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py new file mode 100644 index 00000000000..93d9127f8b8 --- /dev/null +++ b/tests/components/media_player/test_device_trigger.py @@ -0,0 +1,147 @@ +"""The tests for Media player device triggers.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.media_player import DOMAIN +from homeassistant.const import ( + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.helpers import device_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, +) +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_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, device_reg, entity_reg): + """Test we get the expected triggers from a media player.""" + 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) + + trigger_types = {"turned_on", "turned_off", "idle", "paused", "playing"} + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger, + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + } + for trigger in trigger_types + ] + 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 triggers firing.""" + hass.states.async_set("media_player.entity", STATE_OFF) + + data_template = ( + "{label} - {{{{ trigger.platform}}}} - " + "{{{{ trigger.entity_id}}}} - {{{{ trigger.from_state.state}}}} - " + "{{{{ trigger.to_state.state}}}} - {{{{ trigger.for }}}}" + ) + trigger_types = {"turned_on", "turned_off", "idle", "paused", "playing"} + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "media_player.entity", + "type": trigger, + }, + "action": { + "service": "test.automation", + "data_template": {"some": data_template.format(label=trigger)}, + }, + } + for trigger in trigger_types + ] + }, + ) + + # Fake that the entity is turning on. + hass.states.async_set("media_player.entity", STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "turned_on - device - media_player.entity - off - on - None" + ) + + # Fake that the entity is turning off. + hass.states.async_set("media_player.entity", STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 2 + assert ( + calls[1].data["some"] + == "turned_off - device - media_player.entity - on - off - None" + ) + + # Fake that the entity becomes idle. + hass.states.async_set("media_player.entity", STATE_IDLE) + await hass.async_block_till_done() + assert len(calls) == 3 + assert ( + calls[2].data["some"] + == "idle - device - media_player.entity - off - idle - None" + ) + + # Fake that the entity starts playing. + hass.states.async_set("media_player.entity", STATE_PLAYING) + await hass.async_block_till_done() + assert len(calls) == 4 + assert ( + calls[3].data["some"] + == "playing - device - media_player.entity - idle - playing - None" + ) + + # Fake that the entity is paused. + hass.states.async_set("media_player.entity", STATE_PAUSED) + await hass.async_block_till_done() + assert len(calls) == 5 + assert ( + calls[4].data["some"] + == "paused - device - media_player.entity - playing - paused - None" + ) diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 242352c2498..24a81be3896 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -30,6 +30,7 @@ async def test_tracking_home(hass, mock_weather): entry = hass.config_entries.async_entries()[0] await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_entity_ids("weather")) == 0 @@ -63,4 +64,5 @@ async def test_not_tracking_home(hass, mock_weather): entry = hass.config_entries.async_entries()[0] await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_entity_ids("weather")) == 0 diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index 7c611eb1010..db4843c126a 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -7,14 +7,6 @@ from homeassistant.setup import async_setup_component from .const import REGISTER, REGISTER_CLEARTEXT -from tests.common import mock_device_registry - - -@pytest.fixture -def registry(hass): - """Return a configured device registry.""" - return mock_device_registry(hass) - @pytest.fixture async def create_registrations(hass, authed_api_client): diff --git a/tests/components/mobile_app/test_binary_sensor.py b/tests/components/mobile_app/test_binary_sensor.py new file mode 100644 index 00000000000..5ada948a5d6 --- /dev/null +++ b/tests/components/mobile_app/test_binary_sensor.py @@ -0,0 +1,271 @@ +"""Entity tests for mobile_app.""" +from homeassistant.const import STATE_OFF +from homeassistant.helpers import device_registry + + +async def test_sensor(hass, create_registrations, webhook_client): + """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": { + "attributes": {"foo": "bar"}, + "device_class": "plug", + "icon": "mdi:power-plug", + "name": "Is Charging", + "state": True, + "type": "binary_sensor", + "unique_id": "is_charging", + }, + }, + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.test_1_is_charging") + assert entity is not None + + assert entity.attributes["device_class"] == "plug" + assert entity.attributes["icon"] == "mdi:power-plug" + assert entity.attributes["foo"] == "bar" + assert entity.domain == "binary_sensor" + assert entity.name == "Test 1 Is Charging" + assert entity.state == "on" + + update_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "icon": "mdi:battery-unknown", + "state": False, + "type": "binary_sensor", + "unique_id": "is_charging", + }, + # This invalid data should not invalidate whole request + { + "type": "binary_sensor", + "unique_id": "invalid_state", + "invalid": "data", + }, + ], + }, + ) + + assert update_resp.status == 200 + + json = await update_resp.json() + assert json["invalid_state"]["success"] is False + + updated_entity = hass.states.get("binary_sensor.test_1_is_charging") + assert updated_entity.state == "off" + assert "foo" not in updated_entity.attributes + + dev_reg = await device_registry.async_get_registry(hass) + assert len(dev_reg.devices) == len(create_registrations) + + # Reload to verify state is restored + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + unloaded_entity = hass.states.get("binary_sensor.test_1_is_charging") + assert unloaded_entity.state == "unavailable" + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + restored_entity = hass.states.get("binary_sensor.test_1_is_charging") + assert restored_entity.state == updated_entity.state + assert restored_entity.attributes == updated_entity.attributes + + +async def test_sensor_must_register(hass, create_registrations, webhook_client): + """Test that sensors must be registered before updating.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + {"state": True, "type": "binary_sensor", "unique_id": "battery_state"} + ], + }, + ) + + assert resp.status == 200 + + json = await resp.json() + assert json["battery_state"]["success"] is False + assert json["battery_state"]["error"]["code"] == "not_registered" + + +async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client, caplog): + """Test that a duplicate unique ID in registration updates the sensor.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + payload = { + "type": "register_sensor", + "data": { + "attributes": {"foo": "bar"}, + "device_class": "plug", + "icon": "mdi:power-plug", + "name": "Is Charging", + "state": True, + "type": "binary_sensor", + "unique_id": "is_charging", + }, + } + + reg_resp = await webhook_client.post(webhook_url, json=payload) + + assert reg_resp.status == 201 + + reg_json = await reg_resp.json() + assert reg_json == {"success": True} + await hass.async_block_till_done() + + assert "Re-register" not in caplog.text + + entity = hass.states.get("binary_sensor.test_1_is_charging") + assert entity is not None + + assert entity.attributes["device_class"] == "plug" + assert entity.attributes["icon"] == "mdi:power-plug" + assert entity.attributes["foo"] == "bar" + assert entity.domain == "binary_sensor" + assert entity.name == "Test 1 Is Charging" + assert entity.state == "on" + + payload["data"]["state"] = False + dupe_resp = await webhook_client.post(webhook_url, json=payload) + + assert dupe_resp.status == 201 + dupe_reg_json = await dupe_resp.json() + assert dupe_reg_json == {"success": True} + await hass.async_block_till_done() + + assert "Re-register" in caplog.text + + entity = hass.states.get("binary_sensor.test_1_is_charging") + assert entity is not None + + assert entity.attributes["device_class"] == "plug" + assert entity.attributes["icon"] == "mdi:power-plug" + assert entity.attributes["foo"] == "bar" + assert entity.domain == "binary_sensor" + assert entity.name == "Test 1 Is Charging" + assert entity.state == "off" + + +async def test_register_sensor_no_state(hass, create_registrations, webhook_client): + """Test that sensors can be registered, when there is no (unknown) state.""" + 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": { + "name": "Is Charging", + "state": None, + "type": "binary_sensor", + "unique_id": "is_charging", + }, + }, + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.test_1_is_charging") + assert entity is not None + + assert entity.domain == "binary_sensor" + assert entity.name == "Test 1 Is Charging" + assert entity.state == STATE_OFF # Binary sensor defaults to off + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Backup Is Charging", + "type": "binary_sensor", + "unique_id": "backup_is_charging", + }, + }, + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.test_1_backup_is_charging") + assert entity + + assert entity.domain == "binary_sensor" + assert entity.name == "Test 1 Backup Is Charging" + assert entity.state == STATE_OFF # Binary sensor defaults to off + + +async def test_update_sensor_no_state(hass, create_registrations, webhook_client): + """Test that sensors can be updated, when there is no (unknown) state.""" + 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": { + "name": "Is Charging", + "state": True, + "type": "binary_sensor", + "unique_id": "is_charging", + }, + }, + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.test_1_is_charging") + assert entity is not None + assert entity.state == "on" + + update_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + {"state": None, "type": "binary_sensor", "unique_id": "is_charging"} + ], + }, + ) + + assert update_resp.status == 200 + + json = await update_resp.json() + assert json == {"is_charging": {"success": True}} + + updated_entity = hass.states.get("binary_sensor.test_1_is_charging") + assert updated_entity.state == STATE_OFF # Binary sensor defaults to off diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_sensor.py similarity index 92% rename from tests/components/mobile_app/test_entity.py rename to tests/components/mobile_app/test_sensor.py index ba121d766ac..0ba1cf3096d 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_sensor.py @@ -66,10 +66,24 @@ async def test_sensor(hass, create_registrations, webhook_client): updated_entity = hass.states.get("sensor.test_1_battery_state") assert updated_entity.state == "123" + assert "foo" not in updated_entity.attributes dev_reg = await device_registry.async_get_registry(hass) assert len(dev_reg.devices) == len(create_registrations) + # Reload to verify state is restored + config_entry = hass.config_entries.async_entries("mobile_app")[1] + 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" + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + restored_entity = hass.states.get("sensor.test_1_battery_state") + assert restored_entity.state == updated_entity.state + assert restored_entity.attributes == updated_entity.attributes + async def test_sensor_must_register(hass, create_registrations, webhook_client): """Test that sensors must be registered before updating.""" diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 831c8250d7a..a7dc675b7b7 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -109,7 +109,7 @@ async def test_webhook_handle_fire_event(hass, create_registrations, webhook_cli @callback def store_event(event): - """Helepr to store events.""" + """Help store events.""" events.append(event) hass.bus.async_listen("test_event", store_event) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index e3a707b7fc9..403607b110f 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -1,31 +1,25 @@ """The tests for the Modbus sensor component.""" +from datetime import timedelta +import logging from unittest import mock -from unittest.mock import patch import pytest -from homeassistant.components.modbus.const import ( - CALL_TYPE_COIL, - CALL_TYPE_DISCRETE, - CALL_TYPE_REGISTER_INPUT, - DEFAULT_HUB, - MODBUS_DOMAIN as DOMAIN, +from homeassistant.components.modbus.const import DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_TYPE, ) -from homeassistant.const import CONF_PLATFORM, CONF_SCAN_INTERVAL from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed - -@pytest.fixture() -def mock_hub(hass): - """Mock hub.""" - with patch("homeassistant.components.modbus.setup", return_value=True): - hub = mock.MagicMock() - hub.name = "hub" - hass.data[DOMAIN] = {DEFAULT_HUB: hub} - yield hub +_LOGGER = logging.getLogger(__name__) class ReadResult: @@ -37,65 +31,119 @@ class ReadResult: self.bits = register_words -async def setup_base_test( - sensor_name, +async def base_test( hass, - use_mock_hub, - data_array, + config_device, + device_name, entity_domain, - scan_interval, -): - """Run setup device for given config.""" - - # Full sensor configuration - config = { - entity_domain: { - CONF_PLATFORM: "modbus", - CONF_SCAN_INTERVAL: scan_interval, - **data_array, - } - } - - # Initialize sensor - now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - assert await async_setup_component(hass, entity_domain, config) - await hass.async_block_till_done() - - entity_id = f"{entity_domain}.{sensor_name}" - device = hass.states.get(entity_id) - if device is None: - pytest.fail("CONFIG failed, see output") - return entity_id, now, device - - -async def run_base_read_test( - entity_id, - hass, - use_mock_hub, - register_type, + array_name_discovery, + array_name_old_config, register_words, expected, - now, + method_discovery=False, + check_config_only=False, + config_modbus=None, + scan_interval=None, ): - """Run test for given config.""" + """Run test on device for given config.""" - # Setup inputs for the sensor - read_result = ReadResult(register_words) - if register_type == CALL_TYPE_COIL: - use_mock_hub.read_coils.return_value = read_result - elif register_type == CALL_TYPE_DISCRETE: - use_mock_hub.read_discrete_inputs.return_value = read_result - elif register_type == CALL_TYPE_REGISTER_INPUT: - use_mock_hub.read_input_registers.return_value = read_result - else: # CALL_TYPE_REGISTER_HOLDING - use_mock_hub.read_holding_registers.return_value = read_result + if config_modbus is None: + config_modbus = { + DOMAIN: { + CONF_NAME: DEFAULT_HUB, + CONF_TYPE: "tcp", + CONF_HOST: "modbusTest", + CONF_PORT: 5001, + }, + } - # Trigger update call with time_changed event - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + mock_sync = mock.MagicMock() + with mock.patch( + "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_sync + ), mock.patch( + "homeassistant.components.modbus.modbus.ModbusSerialClient", + return_value=mock_sync, + ), mock.patch( + "homeassistant.components.modbus.modbus.ModbusUdpClient", return_value=mock_sync + ): - # Check state - state = hass.states.get(entity_id).state - assert state == expected + # Setup inputs for the sensor + read_result = ReadResult(register_words) + mock_sync.read_coils.return_value = read_result + mock_sync.read_discrete_inputs.return_value = read_result + mock_sync.read_input_registers.return_value = read_result + mock_sync.read_holding_registers.return_value = read_result + + # mock timer and add old/new config + now = dt_util.utcnow() + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + if method_discovery and config_device is not None: + # setup modbus which in turn does setup for the devices + config_modbus[DOMAIN].update( + {array_name_discovery: [{**config_device}]} + ) + config_device = None + assert await async_setup_component(hass, DOMAIN, config_modbus) + await hass.async_block_till_done() + + # setup platform old style + if config_device is not None: + config_device = { + entity_domain: { + CONF_PLATFORM: DOMAIN, + array_name_old_config: [ + { + **config_device, + } + ], + } + } + if scan_interval is not None: + config_device[entity_domain][CONF_SCAN_INTERVAL] = scan_interval + assert await async_setup_component(hass, entity_domain, config_device) + await hass.async_block_till_done() + + assert DOMAIN in hass.data + if config_device is not None: + entity_id = f"{entity_domain}.{device_name}" + device = hass.states.get(entity_id) + if device is None: + pytest.fail("CONFIG failed, see output") + if check_config_only: + return + + # Trigger update call with time_changed event + now = now + timedelta(seconds=scan_interval + 60) + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Check state + entity_id = f"{entity_domain}.{device_name}" + return hass.states.get(entity_id).state + + +async def base_config_test( + hass, + config_device, + device_name, + entity_domain, + array_name_discovery, + array_name_old_config, + method_discovery=False, + config_modbus=None, +): + """Check config of device for given config.""" + + await base_test( + hass, + config_device, + device_name, + entity_domain, + array_name_discovery, + array_name_old_config, + None, + None, + method_discovery=method_discovery, + check_config_only=True, + ) diff --git a/tests/components/modbus/test_modbus_binary_sensor.py b/tests/components/modbus/test_modbus_binary_sensor.py index 91374cde22d..4cd586f390f 100644 --- a/tests/components/modbus/test_modbus_binary_sensor.py +++ b/tests/components/modbus/test_modbus_binary_sensor.py @@ -1,93 +1,83 @@ """The tests for the Modbus sensor component.""" -from datetime import timedelta - import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, - CONF_ADDRESS, CONF_INPUT_TYPE, CONF_INPUTS, ) -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SLAVE, STATE_OFF, STATE_ON -from .conftest import run_base_read_test, setup_base_test +from .conftest import base_config_test, base_test +@pytest.mark.parametrize("do_options", [False, True]) +async def test_config_binary_sensor(hass, do_options): + """Run test for binary sensor.""" + sensor_name = "test_sensor" + config_sensor = { + CONF_NAME: sensor_name, + CONF_ADDRESS: 51, + } + if do_options: + config_sensor.update( + { + CONF_SLAVE: 10, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + } + ) + await base_config_test( + hass, + config_sensor, + sensor_name, + SENSOR_DOMAIN, + None, + CONF_INPUTS, + method_discovery=False, + ) + + +@pytest.mark.parametrize("do_type", [CALL_TYPE_COIL, CALL_TYPE_DISCRETE]) @pytest.mark.parametrize( - "cfg,regs,expected", + "regs,expected", [ ( - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - }, [0xFF], STATE_ON, ), ( - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - }, [0x01], STATE_ON, ), ( - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - }, [0x00], STATE_OFF, ), ( - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - }, [0x80], STATE_OFF, ), ( - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - }, [0xFE], STATE_OFF, ), - ( - { - CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, - }, - [0xFF], - STATE_ON, - ), - ( - { - CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, - }, - [0x00], - STATE_OFF, - ), ], ) -async def test_coil_true(hass, mock_hub, cfg, regs, expected): +async def test_all_binary_sensor(hass, do_type, regs, expected): """Run test for given config.""" sensor_name = "modbus_test_binary_sensor" - scan_interval = 5 - entity_id, now, device = await setup_base_test( + state = await base_test( + hass, + {CONF_NAME: sensor_name, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: do_type}, sensor_name, - hass, - mock_hub, - {CONF_INPUTS: [dict(**{CONF_NAME: sensor_name, CONF_ADDRESS: 1234}, **cfg)]}, SENSOR_DOMAIN, - scan_interval, - ) - await run_base_read_test( - entity_id, - hass, - mock_hub, - cfg.get(CONF_INPUT_TYPE), + None, + CONF_INPUTS, regs, expected, - now + timedelta(seconds=scan_interval + 1), + method_discovery=False, + scan_interval=5, ) + assert state == expected diff --git a/tests/components/modbus/test_modbus_climate.py b/tests/components/modbus/test_modbus_climate.py new file mode 100644 index 00000000000..bbdaed63995 --- /dev/null +++ b/tests/components/modbus/test_modbus_climate.py @@ -0,0 +1,75 @@ +"""The tests for the Modbus climate component.""" +import pytest + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.modbus.const import ( + CONF_CLIMATES, + CONF_CURRENT_TEMP, + CONF_DATA_COUNT, + CONF_TARGET_TEMP, +) +from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_SLAVE + +from .conftest import base_config_test, base_test + + +@pytest.mark.parametrize("do_options", [False, True]) +async def test_config_climate(hass, do_options): + """Run test for climate.""" + device_name = "test_climate" + device_config = { + CONF_NAME: device_name, + CONF_TARGET_TEMP: 117, + CONF_CURRENT_TEMP: 117, + CONF_SLAVE: 10, + } + if do_options: + device_config.update( + { + CONF_SCAN_INTERVAL: 20, + CONF_DATA_COUNT: 2, + } + ) + await base_config_test( + hass, + device_config, + device_name, + CLIMATE_DOMAIN, + CONF_CLIMATES, + None, + method_discovery=True, + ) + + +@pytest.mark.parametrize( + "regs,expected", + [ + ( + [0x00], + "auto", + ), + ], +) +async def test_temperature_climate(hass, regs, expected): + """Run test for given config.""" + climate_name = "modbus_test_climate" + return + state = await base_test( + hass, + { + CONF_NAME: climate_name, + CONF_SLAVE: 1, + CONF_TARGET_TEMP: 117, + CONF_CURRENT_TEMP: 117, + CONF_DATA_COUNT: 2, + }, + climate_name, + CLIMATE_DOMAIN, + CONF_CLIMATES, + None, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + assert state == expected diff --git a/tests/components/modbus/test_modbus_cover.py b/tests/components/modbus/test_modbus_cover.py new file mode 100644 index 00000000000..ff765314745 --- /dev/null +++ b/tests/components/modbus/test_modbus_cover.py @@ -0,0 +1,136 @@ +"""The tests for the Modbus cover component.""" +import pytest + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.modbus.const import CALL_TYPE_COIL, CONF_REGISTER +from homeassistant.const import ( + CONF_COVERS, + CONF_NAME, + CONF_SCAN_INTERVAL, + CONF_SLAVE, + STATE_OPEN, + STATE_OPENING, +) + +from .conftest import base_config_test, base_test + + +@pytest.mark.parametrize("do_options", [False, True]) +@pytest.mark.parametrize("read_type", [CALL_TYPE_COIL, CONF_REGISTER]) +async def test_config_cover(hass, do_options, read_type): + """Run test for cover.""" + device_name = "test_cover" + device_config = { + CONF_NAME: device_name, + read_type: 1234, + } + if do_options: + device_config.update( + { + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 20, + } + ) + await base_config_test( + hass, + device_config, + device_name, + COVER_DOMAIN, + CONF_COVERS, + None, + method_discovery=True, + ) + + +@pytest.mark.parametrize( + "regs,expected", + [ + ( + [0x00], + STATE_OPENING, + ), + ( + [0x80], + STATE_OPENING, + ), + ( + [0xFE], + STATE_OPENING, + ), + ( + [0xFF], + STATE_OPENING, + ), + ( + [0x01], + STATE_OPENING, + ), + ], +) +async def test_coil_cover(hass, regs, expected): + """Run test for given config.""" + cover_name = "modbus_test_cover" + state = await base_test( + hass, + { + CONF_NAME: cover_name, + CALL_TYPE_COIL: 1234, + CONF_SLAVE: 1, + }, + cover_name, + COVER_DOMAIN, + CONF_COVERS, + None, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + assert state == expected + + +@pytest.mark.parametrize( + "regs,expected", + [ + ( + [0x00], + STATE_OPEN, + ), + ( + [0x80], + STATE_OPEN, + ), + ( + [0xFE], + STATE_OPEN, + ), + ( + [0xFF], + STATE_OPEN, + ), + ( + [0x01], + STATE_OPEN, + ), + ], +) +async def test_register_COVER(hass, regs, expected): + """Run test for given config.""" + cover_name = "modbus_test_cover" + state = await base_test( + hass, + { + CONF_NAME: cover_name, + CONF_REGISTER: 1234, + CONF_SLAVE: 1, + }, + cover_name, + COVER_DOMAIN, + CONF_COVERS, + None, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + assert state == expected diff --git a/tests/components/modbus/test_modbus_sensor.py b/tests/components/modbus/test_modbus_sensor.py index 68cdbffa462..71a5213db9e 100644 --- a/tests/components/modbus/test_modbus_sensor.py +++ b/tests/components/modbus/test_modbus_sensor.py @@ -1,6 +1,4 @@ """The tests for the Modbus sensor component.""" -from datetime import timedelta - import pytest from homeassistant.components.modbus.const import ( @@ -8,7 +6,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_COUNT, CONF_DATA_TYPE, - CONF_OFFSET, CONF_PRECISION, CONF_REGISTER, CONF_REGISTER_TYPE, @@ -21,9 +18,41 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_UINT, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_OFFSET, CONF_SLAVE -from .conftest import run_base_read_test, setup_base_test +from .conftest import base_config_test, base_test + + +@pytest.mark.parametrize("do_options", [False, True]) +async def test_config_sensor(hass, do_options): + """Run test for sensor.""" + sensor_name = "test_sensor" + config_sensor = { + CONF_NAME: sensor_name, + CONF_REGISTER: 51, + } + if do_options: + config_sensor.update( + { + CONF_SLAVE: 10, + CONF_COUNT: 1, + CONF_DATA_TYPE: "int", + CONF_PRECISION: 0, + CONF_SCALE: 1, + CONF_REVERSE_ORDER: False, + CONF_OFFSET: 0, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + } + ) + await base_config_test( + hass, + config_sensor, + sensor_name, + SENSOR_DOMAIN, + None, + CONF_REGISTERS, + method_discovery=False, + ) @pytest.mark.parametrize( @@ -236,28 +265,19 @@ from .conftest import run_base_read_test, setup_base_test ), ], ) -async def test_all_sensor(hass, mock_hub, cfg, regs, expected): +async def test_all_sensor(hass, cfg, regs, expected): """Run test for sensor.""" sensor_name = "modbus_test_sensor" - scan_interval = 5 - entity_id, now, device = await setup_base_test( + state = await base_test( + hass, + {CONF_NAME: sensor_name, CONF_REGISTER: 1234, **cfg}, sensor_name, - hass, - mock_hub, - { - CONF_REGISTERS: [ - dict(**{CONF_NAME: sensor_name, CONF_REGISTER: 1234}, **cfg) - ] - }, SENSOR_DOMAIN, - scan_interval, - ) - await run_base_read_test( - entity_id, - hass, - mock_hub, - cfg.get(CONF_REGISTER_TYPE), + None, + CONF_REGISTERS, regs, expected, - now + timedelta(seconds=scan_interval + 1), + method_discovery=False, + scan_interval=5, ) + assert state == expected diff --git a/tests/components/modbus/test_modbus_switch.py b/tests/components/modbus/test_modbus_switch.py index da2ff953660..5c4717c9cf8 100644 --- a/tests/components/modbus/test_modbus_switch.py +++ b/tests/components/modbus/test_modbus_switch.py @@ -1,11 +1,8 @@ """The tests for the Modbus switch component.""" -from datetime import timedelta - import pytest from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, - CALL_TYPE_REGISTER_HOLDING, CONF_COILS, CONF_REGISTER, CONF_REGISTERS, @@ -20,7 +17,43 @@ from homeassistant.const import ( STATE_ON, ) -from .conftest import run_base_read_test, setup_base_test +from .conftest import base_config_test, base_test + + +@pytest.mark.parametrize("do_options", [False, True]) +@pytest.mark.parametrize("read_type", [CALL_TYPE_COIL, CONF_REGISTER]) +async def test_config_switch(hass, do_options, read_type): + """Run test for switch.""" + device_name = "test_switch" + + if read_type == CONF_REGISTER: + device_config = { + CONF_NAME: device_name, + CONF_REGISTER: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + } + array_type = CONF_REGISTERS + else: + device_config = { + CONF_NAME: device_name, + read_type: 1234, + CONF_SLAVE: 10, + } + array_type = CONF_COILS + if do_options: + device_config.update({}) + + await base_config_test( + hass, + device_config, + device_name, + SWITCH_DOMAIN, + None, + array_type, + method_discovery=False, + ) @pytest.mark.parametrize( @@ -48,32 +81,26 @@ from .conftest import run_base_read_test, setup_base_test ), ], ) -async def test_coil_switch(hass, mock_hub, regs, expected): +async def test_coil_switch(hass, regs, expected): """Run test for given config.""" switch_name = "modbus_test_switch" - scan_interval = 5 - entity_id, now, device = await setup_base_test( - switch_name, + state = await base_test( hass, - mock_hub, { - CONF_COILS: [ - {CONF_NAME: switch_name, CALL_TYPE_COIL: 1234, CONF_SLAVE: 1}, - ] + CONF_NAME: switch_name, + CALL_TYPE_COIL: 1234, + CONF_SLAVE: 1, }, + switch_name, SWITCH_DOMAIN, - scan_interval, - ) - - await run_base_read_test( - entity_id, - hass, - mock_hub, - CALL_TYPE_COIL, + None, + CONF_COILS, regs, expected, - now + timedelta(seconds=scan_interval + 1), + method_discovery=False, + scan_interval=5, ) + assert state == expected @pytest.mark.parametrize( @@ -101,38 +128,28 @@ async def test_coil_switch(hass, mock_hub, regs, expected): ), ], ) -async def test_register_switch(hass, mock_hub, regs, expected): +async def test_register_switch(hass, regs, expected): """Run test for given config.""" switch_name = "modbus_test_switch" - scan_interval = 5 - entity_id, now, device = await setup_base_test( - switch_name, + state = await base_test( hass, - mock_hub, { - CONF_REGISTERS: [ - { - CONF_NAME: switch_name, - CONF_REGISTER: 1234, - CONF_SLAVE: 1, - CONF_COMMAND_OFF: 0x00, - CONF_COMMAND_ON: 0x01, - }, - ] + CONF_NAME: switch_name, + CONF_REGISTER: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, }, + switch_name, SWITCH_DOMAIN, - scan_interval, - ) - - await run_base_read_test( - entity_id, - hass, - mock_hub, - CALL_TYPE_REGISTER_HOLDING, + None, + CONF_REGISTERS, regs, expected, - now + timedelta(seconds=scan_interval + 1), + method_discovery=False, + scan_interval=5, ) + assert state == expected @pytest.mark.parametrize( @@ -152,35 +169,25 @@ async def test_register_switch(hass, mock_hub, regs, expected): ), ], ) -async def test_register_state_switch(hass, mock_hub, regs, expected): +async def test_register_state_switch(hass, regs, expected): """Run test for given config.""" switch_name = "modbus_test_switch" - scan_interval = 5 - entity_id, now, device = await setup_base_test( - switch_name, + state = await base_test( hass, - mock_hub, { - CONF_REGISTERS: [ - { - CONF_NAME: switch_name, - CONF_REGISTER: 1234, - CONF_SLAVE: 1, - CONF_COMMAND_OFF: 0x04, - CONF_COMMAND_ON: 0x40, - }, - ] + CONF_NAME: switch_name, + CONF_REGISTER: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_OFF: 0x04, + CONF_COMMAND_ON: 0x40, }, + switch_name, SWITCH_DOMAIN, - scan_interval, - ) - - await run_base_read_test( - entity_id, - hass, - mock_hub, - CALL_TYPE_REGISTER_HOLDING, + None, + CONF_REGISTERS, regs, expected, - now + timedelta(seconds=scan_interval + 1), + method_discovery=False, + scan_interval=5, ) + assert state == expected diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 019f0e19911..44144642f40 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1082,6 +1082,23 @@ async def test_tilt_given_value_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 50}, + blocking=True, + ) + + current_cover_tilt_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_TILT_POSITION + ] + assert current_cover_tilt_position == 50 + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", "50", 0, False + ) + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, @@ -1381,6 +1398,41 @@ async def test_tilt_position(hass, mqtt_mock): ) +async def test_tilt_position_templated(hass, mqtt_mock): + """Test tilt position via template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "tilt_command_topic": "tilt-command-topic", + "tilt_status_topic": "tilt-status-topic", + "tilt_command_template": "{{100-32}}", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 100}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", "68", 0, False + ) + + async def test_tilt_position_altered_range(hass, mqtt_mock): """Test tilt via method invocation with altered range.""" assert await async_setup_component( @@ -1978,3 +2030,297 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG ) + + +async def test_deprecated_value_template_for_position_topic_warning( + hass, caplog, mqtt_mock +): + """Test warning when value_template is used for position_topic.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "position-topic", + "value_template": "{{100-62}}", + } + }, + ) + await hass.async_block_till_done() + + assert ( + "using 'value_template' for 'position_topic' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please replace it with 'position_template'" + ) in caplog.text + + +async def test_deprecated_tilt_invert_state_warning(hass, caplog, mqtt_mock): + """Test warning when tilt_invert_state is used.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "tilt_invert_state": True, + } + }, + ) + await hass.async_block_till_done() + + assert ( + "'tilt_invert_state' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please invert tilt using 'tilt_min' & 'tilt_max'" + ) in caplog.text + + +async def test_no_deprecated_tilt_invert_state_warning(hass, caplog, mqtt_mock): + """Test warning when tilt_invert_state is used.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + } + }, + ) + await hass.async_block_till_done() + + assert ( + "'tilt_invert_state' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please invert tilt using 'tilt_min' & 'tilt_max'" + ) not in caplog.text + + +async def test_no_deprecated_warning_for_position_topic_using_position_template( + hass, caplog, mqtt_mock +): + """Test no warning when position_template is used for position_topic.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "position-topic", + "position_template": "{{100-62}}", + } + }, + ) + await hass.async_block_till_done() + + assert ( + "using 'value_template' for 'position_topic' is deprecated " + "and will be removed from Home Assistant in version 2021.6, " + "please replace it with 'position_template'" + ) not in caplog.text + + +async def test_state_and_position_topics_state_not_set_via_position_topic( + hass, mqtt_mock +): + """Test state is not set via position topic when both state and position topics are set.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "position_topic": "get-position-topic", + "position_open": 100, + "position_closed": 0, + "state_open": "OPEN", + "state_closed": "CLOSE", + "command_topic": "command-topic", + "qos": 0, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "OPEN") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "get-position-topic", "0") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "get-position-topic", "100") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "state-topic", "CLOSE") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + async_fire_mqtt_message(hass, "get-position-topic", "0") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + async_fire_mqtt_message(hass, "get-position-topic", "100") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + +async def test_set_state_via_position_using_stopped_state(hass, mqtt_mock): + """Test the controlling state via position topic using stopped state.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "position_topic": "get-position-topic", + "position_open": 100, + "position_closed": 0, + "state_open": "OPEN", + "state_closed": "CLOSE", + "state_stopped": "STOPPED", + "command_topic": "command-topic", + "qos": 0, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "OPEN") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "get-position-topic", "0") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + async_fire_mqtt_message(hass, "get-position-topic", "100") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + +async def test_position_via_position_topic_template(hass, mqtt_mock): + """Test position by updating status via position template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": "{{ (value | multiply(0.01)) | int }}", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "99") + + current_cover_position_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position_position == 0 + + async_fire_mqtt_message(hass, "get-position-topic", "5000") + + current_cover_position_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position_position == 50 + + +async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): + """Test the controlling state via stopped state when no position topic.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "state_open": "OPEN", + "state_closed": "CLOSE", + "state_stopped": "STOPPED", + "state_opening": "OPENING", + "state_closing": "CLOSING", + "command_topic": "command-topic", + "qos": 0, + "optimistic": False, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "state-topic", "OPEN") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "state-topic", "OPENING") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPENING + + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "state-topic", "CLOSING") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSING + + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED diff --git a/tests/components/mqtt/test_device_tracker_discovery.py b/tests/components/mqtt/test_device_tracker_discovery.py index 2c445ee0fa5..f158a878fcd 100644 --- a/tests/components/mqtt/test_device_tracker_discovery.py +++ b/tests/components/mqtt/test_device_tracker_discovery.py @@ -194,6 +194,7 @@ async def test_cleanup_device_tracker(hass, device_reg, entity_reg, mqtt_mock): device_reg.async_remove_device(device_entry.id) await hass.async_block_till_done() + await hass.async_block_till_done() # Verify device and registry entries are cleared device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index f200de6a274..210dac19e0c 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -290,6 +290,81 @@ async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock): assert calls[1].data["some"] == "long_press" +async def test_if_fires_on_mqtt_message_template(hass, device_reg, calls, mqtt_mock): + """Test triggers firing.""" + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + " \"payload\": \"{{ 'foo_press'|regex_replace('foo', 'short') }}\"," + ' "topic": "foobar/triggers/button{{ sqrt(16)|round }}",' + ' "type": "button_short_press",' + ' "subtype": "button_1",' + ' "value_template": "{{ value_json.button }}"}' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + " \"payload\": \"{{ 'foo_press'|regex_replace('foo', 'long') }}\"," + ' "topic": "foobar/triggers/button{{ sqrt(16)|round }}",' + ' "type": "button_long_press",' + ' "subtype": "button_2",' + ' "value_template": "{{ value_json.button }}"}' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla2", + "type": "button_1", + "subtype": "button_long_press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("long_press")}, + }, + }, + ] + }, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button4", '{"button":"short_press"}') + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "short_press" + + # Fake long press. + async_fire_mqtt_message(hass, "foobar/triggers/button4", '{"button":"long_press"}') + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "long_press" + + async def test_if_fires_on_mqtt_message_late_discover( hass, device_reg, calls, mqtt_mock ): diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index c9b0879d490..fed0dfa54d6 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -411,6 +411,7 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): device_reg.async_remove_device(device_entry.id) await hass.async_block_till_done() + await hass.async_block_till_done() # Verify device and registry entries are cleared device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 733a39ce252..7b5d34edd69 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -677,7 +677,7 @@ async def test_transition(hass, mqtt_mock): "name": "test", "command_topic": "test_light_rgb/set", "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", + "command_off_template": "off,{{ transition|int|d }}", "qos": 1, } }, @@ -689,15 +689,15 @@ async def test_transition(hass, mqtt_mock): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40 - await common.async_turn_on(hass, "light.test", transition=10) + await common.async_turn_on(hass, "light.test", transition=10.0) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,10", 1, False + "test_light_rgb/set", "on,10.0", 1, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - await common.async_turn_off(hass, "light.test", transition=20) + await common.async_turn_off(hass, "light.test", transition=20.0) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", "off,20", 1, False ) diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index b27af2b9bd0..d0a86e08655 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -81,6 +81,114 @@ async def test_if_fires_on_topic_and_payload_match(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_topic_and_payload_match2(hass, calls): + """Test if message is fired on topic and payload match. + + Make sure a payload which would render as a non string can still be matched. + """ + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "mqtt", + "topic": "test-topic", + "payload": "0", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_mqtt_message(hass, "test-topic", "0") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_templated_topic_and_payload_match(hass, calls): + """Test if message is fired on templated topic and payload match.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "mqtt", + "topic": "test-topic-{{ sqrt(16)|round }}", + "payload": '{{ "foo"|regex_replace("foo", "bar") }}', + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_mqtt_message(hass, "test-topic-", "foo") + await hass.async_block_till_done() + assert len(calls) == 0 + + async_fire_mqtt_message(hass, "test-topic-4", "foo") + await hass.async_block_till_done() + assert len(calls) == 0 + + async_fire_mqtt_message(hass, "test-topic-4", "bar") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_payload_template(hass, calls): + """Test if message is fired on templated topic and payload match.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "mqtt", + "topic": "test-topic", + "payload": "hello", + "value_template": "{{ value_json.wanted_key }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_mqtt_message(hass, "test-topic", "hello") + await hass.async_block_till_done() + assert len(calls) == 0 + + async_fire_mqtt_message(hass, "test-topic", '{"unwanted_key":"hello"}') + await hass.async_block_till_done() + assert len(calls) == 0 + + async_fire_mqtt_message(hass, "test-topic", '{"wanted_key":"hello"}') + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_non_allowed_templates(hass, calls, caplog): + """Test non allowed function in template.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "mqtt", + "topic": "test-topic-{{ states() }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + assert ( + "Got error 'TemplateError: str: Use of 'states' is not supported in limited templates' when setting up triggers" + in caplog.text + ) + + async def test_if_not_fires_on_topic_but_no_payload_match(hass, calls): """Test if message is not fired on topic but no payload.""" assert await async_setup_component( diff --git a/tests/components/mullvad/__init__.py b/tests/components/mullvad/__init__.py new file mode 100644 index 00000000000..dc940265eac --- /dev/null +++ b/tests/components/mullvad/__init__.py @@ -0,0 +1 @@ +"""Tests for the mullvad component.""" diff --git a/tests/components/mullvad/test_config_flow.py b/tests/components/mullvad/test_config_flow.py new file mode 100644 index 00000000000..c101e5a7246 --- /dev/null +++ b/tests/components/mullvad/test_config_flow.py @@ -0,0 +1,94 @@ +"""Test the Mullvad config flow.""" +from unittest.mock import patch + +from mullvad_api import MullvadAPIError + +from homeassistant import config_entries, setup +from homeassistant.components.mullvad.const import DOMAIN +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form_user(hass): + """Test we can setup by the user.""" + await setup.async_setup_component(hass, DOMAIN, {}) + 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.mullvad.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mullvad.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.mullvad.config_flow.MullvadAPI" + ) as mock_mullvad_api: + 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"] == "Mullvad VPN" + assert result2["data"] == {} + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_mullvad_api.mock_calls) == 1 + + +async def test_form_user_only_once(hass): + """Test we can setup by the user only once.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + 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"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass): + """Test we show an error when we have trouble connecting.""" + await setup.async_setup_component(hass, DOMAIN, {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.mullvad.config_flow.MullvadAPI", + side_effect=MullvadAPIError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_unknown_error(hass): + """Test we show an error when an unknown error occurs.""" + await setup.async_setup_component(hass, DOMAIN, {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.mullvad.config_flow.MullvadAPI", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/my/__init__.py b/tests/components/my/__init__.py new file mode 100644 index 00000000000..82953c8dac2 --- /dev/null +++ b/tests/components/my/__init__.py @@ -0,0 +1 @@ +"""Tests for the my component.""" diff --git a/tests/components/my/test_init.py b/tests/components/my/test_init.py new file mode 100644 index 00000000000..86929271be9 --- /dev/null +++ b/tests/components/my/test_init.py @@ -0,0 +1,17 @@ +"""Test the my init.""" + +from unittest import mock + +from homeassistant.components.my import URL_PATH +from homeassistant.setup import async_setup_component + + +async def test_setup(hass): + """Test setup.""" + with mock.patch( + "homeassistant.components.frontend.async_register_built_in_panel" + ) as mock_register_panel: + assert await async_setup_component(hass, "my", {"foo": "bar"}) + assert mock_register_panel.call_args == mock.call( + hass, "my", frontend_url_path=URL_PATH + ) diff --git a/tests/components/mysensors/__init__.py b/tests/components/mysensors/__init__.py new file mode 100644 index 00000000000..68fc6d7b4d7 --- /dev/null +++ b/tests/components/mysensors/__init__.py @@ -0,0 +1 @@ +"""Tests for the MySensors integration.""" diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py new file mode 100644 index 00000000000..5fd9e3e7ea1 --- /dev/null +++ b/tests/components/mysensors/test_config_flow.py @@ -0,0 +1,738 @@ +"""Test the MySensors config flow.""" +from typing import Dict, Optional, Tuple +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.mysensors.const import ( + CONF_BAUD_RATE, + CONF_DEVICE, + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_PERSISTENCE, + CONF_PERSISTENCE_FILE, + CONF_RETAIN, + CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, + CONF_VERSION, + DOMAIN, + ConfGatewayType, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + + +async def get_form( + hass: HomeAssistantType, gatway_type: ConfGatewayType, expected_step_id: str +): + """Get a form for the given gateway type.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + stepuser = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert stepuser["type"] == "form" + assert not stepuser["errors"] + + result = await hass.config_entries.flow.async_configure( + stepuser["flow_id"], + {CONF_GATEWAY_TYPE: gatway_type}, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == expected_step_id + + return result + + +async def test_config_mqtt(hass: HomeAssistantType): + """Test configuring a mqtt gateway.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_MQTT, "gw_mqtt") + flow_id = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "mqtt" + assert result2["data"] == { + CONF_DEVICE: "mqtt", + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + CONF_GATEWAY_TYPE: "MQTT", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_serial(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_SERIAL, "gw_serial") + flow_id = step["flow_id"] + + with patch( # mock is_serial_port because otherwise the test will be platform dependent (/dev/ttyACMx vs COMx) + "homeassistant.components.mysensors.config_flow.is_serial_port", + return_value=True, + ), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_BAUD_RATE: 115200, + CONF_DEVICE: "/dev/ttyACM0", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "/dev/ttyACM0" + assert result2["data"] == { + CONF_DEVICE: "/dev/ttyACM0", + CONF_BAUD_RATE: 115200, + CONF_VERSION: "2.4", + CONF_GATEWAY_TYPE: "Serial", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_tcp(hass: HomeAssistantType): + """Test configuring a gateway via tcp.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_TCP, "gw_tcp") + flow_id = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1" + assert result2["data"] == { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.4", + CONF_GATEWAY_TYPE: "TCP", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_fail_to_connect(hass: HomeAssistantType): + """Test configuring a gateway via tcp.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_TCP, "gw_tcp") + flow_id = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=False + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert "errors" in result2 + assert "base" in result2["errors"] + assert result2["errors"]["base"] == "cannot_connect" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize( + "gateway_type, expected_step_id, user_input, err_field, err_string", + [ + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 600_000, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + CONF_TCP_PORT, + "port_out_of_range", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 0, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + CONF_TCP_PORT, + "port_out_of_range", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a.b", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "4", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "v3", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.", + }, + CONF_DEVICE, + "invalid_ip", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "abcd", + }, + CONF_DEVICE, + "invalid_ip", + ), + ( + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_PERSISTENCE_FILE: "asdf.zip", + CONF_VERSION: "2.4", + }, + CONF_PERSISTENCE_FILE, + "invalid_persistence_file", + ), + ( + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "/#/#", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_IN_PREFIX, + "invalid_subscribe_topic", + ), + ( + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "asdf", + CONF_TOPIC_OUT_PREFIX: "/#/#", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_OUT_PREFIX, + "invalid_publish_topic", + ), + ( + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "asdf", + CONF_TOPIC_OUT_PREFIX: "asdf", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_OUT_PREFIX, + "same_topic", + ), + ], +) +async def test_config_invalid( + hass: HomeAssistantType, + gateway_type: ConfGatewayType, + expected_step_id: str, + user_input: Dict[str, any], + err_field, + err_string, +): + """Perform a test that is expected to generate an error.""" + step = await get_form(hass, gateway_type, expected_step_id) + flow_id = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id, + user_input, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert "errors" in result2 + assert err_field in result2["errors"] + assert result2["errors"][err_field] == err_string + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 57600, + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "bla.json", + }, + { + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: True, + }, + { + CONF_DEVICE: "mqtt", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_TOPIC_IN_PREFIX: "intopic", + CONF_TOPIC_OUT_PREFIX: "outtopic", + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "blub.pickle", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 343, + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + ], +) +async def test_import(hass: HomeAssistantType, user_input: Dict): + """Test importing a gateway.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch("sys.platform", "win32"), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, data=user_input, context={"source": config_entries.SOURCE_IMPORT} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + + +@pytest.mark.parametrize( + "first_input, second_input, expected_result", + [ + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "same2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "same2", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different3", + CONF_TOPIC_OUT_PREFIX: "different4", + }, + None, + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different4", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different1", + CONF_TOPIC_OUT_PREFIX: "same1", + }, + (CONF_TOPIC_OUT_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different1", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + ("persistence_file", "duplicate_persistence_file"), + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different1.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different2.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different1.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different2.json", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.3", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different1.json", + }, + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different2.json", + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "COM6", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + }, + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + }, + None, + ), + ( + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 115200, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different1.json", + }, + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different2.json", + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 115200, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "same.json", + }, + { + CONF_DEVICE: "COM6", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "same.json", + }, + ("persistence_file", "duplicate_persistence_file"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_VERSION: "1.4", + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla2.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_VERSION: "1.4", + }, + None, + ), + ], +) +async def test_duplicate( + hass: HomeAssistantType, + first_input: Dict, + second_input: Dict, + expected_result: Optional[Tuple[str, str]], +): + """Test duplicate detection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch("sys.platform", "win32"), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ): + MockConfigEntry(domain=DOMAIN, data=first_input).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, data=second_input, context={"source": config_entries.SOURCE_IMPORT} + ) + await hass.async_block_till_done() + if expected_result is None: + assert result["type"] == "create_entry" + else: + assert result["type"] == "abort" + assert result["reason"] == expected_result[1] diff --git a/tests/components/mysensors/test_gateway.py b/tests/components/mysensors/test_gateway.py new file mode 100644 index 00000000000..d3e360e0b9f --- /dev/null +++ b/tests/components/mysensors/test_gateway.py @@ -0,0 +1,30 @@ +"""Test function in gateway.py.""" +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components.mysensors.gateway import is_serial_port +from homeassistant.helpers.typing import HomeAssistantType + + +@pytest.mark.parametrize( + "port, expect_valid", + [ + ("COM5", True), + ("asdf", False), + ("COM17", True), + ("COM", False), + ("/dev/ttyACM0", False), + ], +) +def test_is_serial_port_windows(hass: HomeAssistantType, port: str, expect_valid: bool): + """Test windows serial port.""" + + with patch("sys.platform", "win32"): + try: + is_serial_port(port) + except vol.Invalid: + assert not expect_valid + else: + assert expect_valid diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py new file mode 100644 index 00000000000..2775b73efd6 --- /dev/null +++ b/tests/components/mysensors/test_init.py @@ -0,0 +1,251 @@ +"""Test function in __init__.py.""" +from typing import Dict +from unittest.mock import patch + +import pytest + +from homeassistant.components.mysensors import ( + CONF_BAUD_RATE, + CONF_DEVICE, + CONF_GATEWAYS, + CONF_PERSISTENCE, + CONF_PERSISTENCE_FILE, + CONF_RETAIN, + CONF_TCP_PORT, + CONF_VERSION, + DEFAULT_VERSION, + DOMAIN, +) +from homeassistant.components.mysensors.const import ( + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.setup import async_setup_component + + +@pytest.mark.parametrize( + "config, expected_calls, expected_to_succeed, expected_config_flow_user_input", + [ + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_TCP_PORT: 5003, + } + ], + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: True, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_VERSION: "2.3", + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "blub.pickle", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 343, + } + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "blub.pickle", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.4", + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "127.0.0.1", + } + ], + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 5003, + CONF_VERSION: DEFAULT_VERSION, + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_TOPIC_IN_PREFIX: "intopic", + CONF_TOPIC_OUT_PREFIX: "outtopic", + } + ], + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, + CONF_DEVICE: "mqtt", + CONF_VERSION: DEFAULT_VERSION, + CONF_TOPIC_OUT_PREFIX: "outtopic", + CONF_TOPIC_IN_PREFIX: "intopic", + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + } + ], + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 0, + True, + {}, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_TOPIC_OUT_PREFIX: "out", + CONF_TOPIC_IN_PREFIX: "in", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla2.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 2, + True, + {}, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 0, + False, + {}, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "COMx", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 0, + True, + {}, + ), + ], +) +async def test_import( + hass: HomeAssistantType, + config: ConfigType, + expected_calls: int, + expected_to_succeed: bool, + expected_config_flow_user_input: Dict[str, any], +): + """Test importing a gateway.""" + with patch("sys.platform", "win32"), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await async_setup_component(hass, DOMAIN, config) + assert result == expected_to_succeed + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == expected_calls + + if expected_calls > 0: + config_flow_user_input = mock_setup_entry.mock_calls[0][1][1].data + for key, value in expected_config_flow_user_input.items(): + assert key in config_flow_user_input + assert config_flow_user_input[key] == value diff --git a/tests/components/nest/camera_sdm_test.py b/tests/components/nest/camera_sdm_test.py index 84deef92d62..956d6036aed 100644 --- a/tests/components/nest/camera_sdm_test.py +++ b/tests/components/nest/camera_sdm_test.py @@ -16,6 +16,7 @@ import pytest from homeassistant.components import camera from homeassistant.components.camera import STATE_IDLE from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from .common import async_setup_sdm_platform @@ -245,12 +246,17 @@ async def test_refresh_expired_stream_token(hass, auth): DEVICE_TRAITS, auth=auth, ) + assert await async_setup_component(hass, "stream", {}) assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None assert cam.state == STATE_IDLE + # Request a stream for the camera entity to exercise nest cam + camera interaction + # and shutdown on url expiration + await camera.async_request_stream(hass, cam.entity_id, "hls") + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.1.streamingToken" @@ -339,6 +345,7 @@ async def test_camera_removed(hass, auth): for config_entry in hass.config_entries.async_entries(DOMAIN): await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 diff --git a/tests/components/nest/climate_sdm_test.py b/tests/components/nest/climate_sdm_test.py index ef332d0e848..888227b9cde 100644 --- a/tests/components/nest/climate_sdm_test.py +++ b/tests/components/nest/climate_sdm_test.py @@ -819,6 +819,20 @@ async def test_thermostat_set_fan(hass, auth): "params": {"timerMode": "OFF"}, } + # Turn on fan mode + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "43200s", + "timerMode": "ON", + }, + } + async def test_thermostat_fan_empty(hass): """Test a fan trait with an empty response.""" @@ -938,7 +952,7 @@ async def test_thermostat_set_hvac_fan_only(hass, auth): assert url == "some-device-id:executeCommand" assert json == { "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"timerMode": "ON"}, + "params": {"duration": "43200s", "timerMode": "ON"}, } (method, url, json, headers) = auth.captured_requests.pop(0) assert method == "post" diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py new file mode 100644 index 00000000000..7e014d2648f --- /dev/null +++ b/tests/components/netatmo/test_device_trigger.py @@ -0,0 +1,311 @@ +"""The tests for Netatmo device triggers.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.netatmo import DOMAIN as NETATMO_DOMAIN +from homeassistant.components.netatmo.const import ( + CLIMATE_TRIGGERS, + INDOOR_CAMERA_TRIGGERS, + MODEL_NACAMERA, + MODEL_NAPLUG, + MODEL_NATHERM1, + MODEL_NOC, + MODEL_NRV, + NETATMO_EVENT, + OUTDOOR_CAMERA_TRIGGERS, +) +from homeassistant.components.netatmo.device_trigger import SUBTYPES +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_capture_events, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +@pytest.mark.parametrize( + "platform,device_type,event_types", + [ + ("camera", MODEL_NOC, OUTDOOR_CAMERA_TRIGGERS), + ("camera", MODEL_NACAMERA, INDOOR_CAMERA_TRIGGERS), + ("climate", MODEL_NRV, CLIMATE_TRIGGERS), + ("climate", MODEL_NATHERM1, CLIMATE_TRIGGERS), + ], +) +async def test_get_triggers( + hass, device_reg, entity_reg, platform, device_type, event_types +): + """Test we get the expected triggers from a netatmo devices.""" + config_entry = MockConfigEntry(domain=NETATMO_DOMAIN, 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")}, + model=device_type, + ) + entity_reg.async_get_or_create( + platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id + ) + expected_triggers = [] + for event_type in event_types: + if event_type in SUBTYPES: + for subtype in SUBTYPES[event_type]: + expected_triggers.append( + { + "platform": "device", + "domain": NETATMO_DOMAIN, + "type": event_type, + "subtype": subtype, + "device_id": device_entry.id, + "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + } + ) + else: + expected_triggers.append( + { + "platform": "device", + "domain": NETATMO_DOMAIN, + "type": event_type, + "device_id": device_entry.id, + "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + } + ) + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, "trigger", device_entry.id + ) + if trigger["domain"] == NETATMO_DOMAIN + ] + assert_lists_same(triggers, expected_triggers) + + +@pytest.mark.parametrize( + "platform,camera_type,event_type", + [("camera", MODEL_NOC, trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS] + + [("camera", MODEL_NACAMERA, trigger) for trigger in INDOOR_CAMERA_TRIGGERS] + + [ + ("climate", MODEL_NRV, trigger) + for trigger in CLIMATE_TRIGGERS + if trigger not in SUBTYPES + ] + + [ + ("climate", MODEL_NATHERM1, trigger) + for trigger in CLIMATE_TRIGGERS + if trigger not in SUBTYPES + ], +) +async def test_if_fires_on_event( + hass, calls, device_reg, entity_reg, platform, camera_type, event_type +): + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (device_registry.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=NETATMO_DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(NETATMO_DOMAIN, mac_address)}, + model=camera_type, + ) + entity_reg.async_get_or_create( + platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id + ) + events = async_capture_events(hass, "netatmo_event") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": NETATMO_DOMAIN, + "device_id": device_entry.id, + "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "type": event_type, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "{{trigger.event.data.type}} - {{trigger.platform}} - {{trigger.event.data.device_id}}" + ) + }, + }, + }, + ] + }, + ) + + device = device_reg.async_get_device(set(), {connection}) + assert device is not None + + # Fake that the entity is turning on. + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={ + "type": event_type, + ATTR_DEVICE_ID: device.id, + }, + ) + await hass.async_block_till_done() + assert len(events) == 1 + assert len(calls) == 1 + assert calls[0].data["some"] == f"{event_type} - device - {device.id}" + + +@pytest.mark.parametrize( + "platform,camera_type,event_type,sub_type", + [ + ("climate", MODEL_NRV, trigger, subtype) + for trigger in SUBTYPES + for subtype in SUBTYPES[trigger] + ] + + [ + ("climate", MODEL_NATHERM1, trigger, subtype) + for trigger in SUBTYPES + for subtype in SUBTYPES[trigger] + ], +) +async def test_if_fires_on_event_with_subtype( + hass, calls, device_reg, entity_reg, platform, camera_type, event_type, sub_type +): + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (device_registry.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=NETATMO_DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(NETATMO_DOMAIN, mac_address)}, + model=camera_type, + ) + entity_reg.async_get_or_create( + platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id + ) + events = async_capture_events(hass, "netatmo_event") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": NETATMO_DOMAIN, + "device_id": device_entry.id, + "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "type": event_type, + "subtype": sub_type, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "{{trigger.event.data.type}} - {{trigger.event.data.data.mode}} - " + "{{trigger.platform}} - {{trigger.event.data.device_id}}" + ) + }, + }, + }, + ] + }, + ) + + device = device_reg.async_get_device(set(), {connection}) + assert device is not None + + # Fake that the entity is turning on. + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={ + "type": event_type, + "data": { + "mode": sub_type, + }, + ATTR_DEVICE_ID: device.id, + }, + ) + await hass.async_block_till_done() + assert len(events) == 1 + assert len(calls) == 1 + assert calls[0].data["some"] == f"{event_type} - {sub_type} - device - {device.id}" + + +@pytest.mark.parametrize( + "platform,device_type,event_type", + [("climate", MODEL_NAPLUG, trigger) for trigger in CLIMATE_TRIGGERS], +) +async def test_if_invalid_device( + hass, device_reg, entity_reg, platform, device_type, event_type +): + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (device_registry.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=NETATMO_DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(NETATMO_DOMAIN, mac_address)}, + model=device_type, + ) + entity_reg.async_get_or_create( + platform, NETATMO_DOMAIN, "5678", device_id=device_entry.id + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": NETATMO_DOMAIN, + "device_id": device_entry.id, + "entity_id": f"{platform}.{NETATMO_DOMAIN}_5678", + "type": event_type, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "{{trigger.event.data.type}} - {{trigger.platform}} - {{trigger.event.data.device_id}}" + ) + }, + }, + }, + ] + }, + ) diff --git a/tests/components/nightscout/test_sensor.py b/tests/components/nightscout/test_sensor.py index 3df98a2595a..5e73c75d93c 100644 --- a/tests/components/nightscout/test_sensor.py +++ b/tests/components/nightscout/test_sensor.py @@ -1,12 +1,11 @@ """The sensor tests for the Nightscout platform.""" from homeassistant.components.nightscout.const import ( - ATTR_DATE, ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, ) -from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_DATE, ATTR_ICON, STATE_UNAVAILABLE from tests.components.nightscout import ( GLUCOSE_READINGS, diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py new file mode 100644 index 00000000000..a774935b9db --- /dev/null +++ b/tests/components/nuki/__init__.py @@ -0,0 +1 @@ +"""The tests for nuki integration.""" diff --git a/tests/components/nuki/mock.py b/tests/components/nuki/mock.py new file mode 100644 index 00000000000..a7870ce0906 --- /dev/null +++ b/tests/components/nuki/mock.py @@ -0,0 +1,25 @@ +"""Mockup Nuki device.""" +from homeassistant import setup + +from tests.common import MockConfigEntry + +NAME = "Nuki_Bridge_75BCD15" +HOST = "1.1.1.1" +MAC = "01:23:45:67:89:ab" + +HW_ID = 123456789 + +MOCK_INFO = {"ids": {"hardwareId": HW_ID}} + + +async def setup_nuki_integration(hass): + """Create the Nuki device.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain="nuki", + unique_id=HW_ID, + data={"host": HOST, "port": 8080, "token": "test-token"}, + ) + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py new file mode 100644 index 00000000000..4933ea52b77 --- /dev/null +++ b/tests/components/nuki/test_config_flow.py @@ -0,0 +1,229 @@ +"""Test the nuki config flow.""" +from unittest.mock import patch + +from pynuki.bridge import InvalidCredentialsException +from requests.exceptions import RequestException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components.nuki.const import DOMAIN + +from .mock import HOST, MAC, MOCK_INFO, NAME, setup_nuki_integration + + +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 result["errors"] == {} + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + return_value=MOCK_INFO, + ), patch( + "homeassistant.components.nuki.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuki.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", + "port": 8080, + "token": "test-token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == 123456789 + assert result2["data"] == { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + } + 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.nuki.config_flow.NukiBridge.info", + return_value=MOCK_INFO, + ), patch( + "homeassistant.components.nuki.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuki.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={"host": "1.1.1.1", "port": 8080, "token": "test-token"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == 123456789 + assert result["data"] == { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=InvalidCredentialsException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=RequestException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_already_configured(hass): + """Test we get the form.""" + await setup_nuki_integration(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + return_value=MOCK_INFO, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +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}, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + return_value=MOCK_INFO, + ), patch( + "homeassistant.components.nuki.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuki.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", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == 123456789 + assert result2["data"] == { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_already_configured(hass): + """Test that DHCP doesn't setup already configured devices.""" + await setup_nuki_integration(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={HOSTNAME: NAME, IP_ADDRESS: HOST, MAC_ADDRESS: MAC}, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/nws/test_init.py b/tests/components/nws/test_init.py index 44b5193d79c..01a203aa07b 100644 --- a/tests/components/nws/test_init.py +++ b/tests/components/nws/test_init.py @@ -1,6 +1,7 @@ """Tests for init module.""" from homeassistant.components.nws.const import DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from tests.common import MockConfigEntry from tests.components.nws.const import NWS_CONFIG @@ -25,5 +26,12 @@ async def test_unload_entry(hass, mock_simple_nws): assert len(entries) == 1 assert await hass.config_entries.async_unload(entries[0].entry_id) - assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + entities = hass.states.async_entity_ids(WEATHER_DOMAIN) + assert len(entities) == 1 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE assert DOMAIN not in hass.data + + assert await hass.config_entries.async_remove(entries[0].entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 4fa6b8da78a..fe956b2ac0a 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -247,7 +247,7 @@ async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): assert sorted([res1.status, res2.status]) == [200, HTTP_FORBIDDEN] -async def test_onboarding_integration(hass, hass_storage, hass_client): +async def test_onboarding_integration(hass, hass_storage, hass_client, hass_admin_user): """Test finishing integration step.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) @@ -288,6 +288,28 @@ async def test_onboarding_integration(hass, hass_storage, hass_client): assert len(user.refresh_tokens) == 2, user +async def test_onboarding_integration_missing_credential( + hass, hass_storage, hass_client, hass_access_token +): + """Test that we fail integration step if user is missing credentials.""" + mock_storage(hass_storage, {"done": [const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token.credential = None + + client = await hass_client() + + resp = await client.post( + "/api/onboarding/integration", + json={"client_id": CLIENT_ID, "redirect_uri": CLIENT_REDIRECT_URI}, + ) + + assert resp.status == 403 + + async def test_onboarding_integration_invalid_redirect_uri( hass, hass_storage, hass_client ): diff --git a/tests/components/ozw/test_fan.py b/tests/components/ozw/test_fan.py index cca1143c44b..5556b663f6f 100644 --- a/tests/components/ozw/test_fan.py +++ b/tests/components/ozw/test_fan.py @@ -1,5 +1,5 @@ -"""Test Z-Wave Lights.""" -from homeassistant.components.ozw.fan import SPEED_TO_VALUE +"""Test Z-Wave Fans.""" +import pytest from .common import setup_ozw @@ -38,11 +38,10 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): assert state.state == "off" # Test turning on - new_speed = "medium" await hass.services.async_call( "fan", "turn_on", - {"entity_id": "fan.in_wall_smart_fan_control_level", "speed": new_speed}, + {"entity_id": "fan.in_wall_smart_fan_control_level", "percentage": 66}, blocking=True, ) @@ -50,13 +49,13 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): msg = sent_messages[-1] assert msg["topic"] == "OpenZWave/1/command/setvalue/" assert msg["payload"] == { - "Value": SPEED_TO_VALUE[new_speed], + "Value": 66, "ValueIDKey": 172589073, } # Feedback on state fan_msg.decode() - fan_msg.payload["Value"] = SPEED_TO_VALUE[new_speed] + fan_msg.payload["Value"] = 66 fan_msg.encode() receive_message(fan_msg) await hass.async_block_till_done() @@ -64,7 +63,7 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): state = hass.states.get("fan.in_wall_smart_fan_control_level") assert state is not None assert state.state == "on" - assert state.attributes["speed"] == new_speed + assert state.attributes["percentage"] == 66 # Test turn on without speed await hass.services.async_call( @@ -84,7 +83,7 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): # Feedback on state fan_msg.decode() - fan_msg.payload["Value"] = SPEED_TO_VALUE[new_speed] + fan_msg.payload["Value"] = 99 fan_msg.encode() receive_message(fan_msg) await hass.async_block_till_done() @@ -92,14 +91,13 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): state = hass.states.get("fan.in_wall_smart_fan_control_level") assert state is not None assert state.state == "on" - assert state.attributes["speed"] == new_speed + assert state.attributes["percentage"] == 100 - # Test set speed to off - new_speed = "off" + # Test set percentage to 0 await hass.services.async_call( "fan", - "set_speed", - {"entity_id": "fan.in_wall_smart_fan_control_level", "speed": new_speed}, + "set_percentage", + {"entity_id": "fan.in_wall_smart_fan_control_level", "percentage": 0}, blocking=True, ) @@ -107,13 +105,13 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): msg = sent_messages[-1] assert msg["topic"] == "OpenZWave/1/command/setvalue/" assert msg["payload"] == { - "Value": SPEED_TO_VALUE[new_speed], + "Value": 0, "ValueIDKey": 172589073, } # Feedback on state fan_msg.decode() - fan_msg.payload["Value"] = SPEED_TO_VALUE[new_speed] + fan_msg.payload["Value"] = 0 fan_msg.encode() receive_message(fan_msg) await hass.async_block_till_done() @@ -124,12 +122,10 @@ async def test_fan(hass, fan_data, fan_msg, sent_messages, caplog): # Test invalid speed new_speed = "invalid" - await hass.services.async_call( - "fan", - "set_speed", - {"entity_id": "fan.in_wall_smart_fan_control_level", "speed": new_speed}, - blocking=True, - ) - - assert len(sent_messages) == 4 - assert "Invalid speed received: invalid" in caplog.text + with pytest.raises(ValueError): + await hass.services.async_call( + "fan", + "set_speed", + {"entity_id": "fan.in_wall_smart_fan_control_level", "speed": new_speed}, + blocking=True, + ) diff --git a/tests/components/ozw/test_init.py b/tests/components/ozw/test_init.py index c76bfd4a3a0..339b690f4e4 100644 --- a/tests/components/ozw/test_init.py +++ b/tests/components/ozw/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.ozw import DOMAIN, PLATFORMS, const +from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE from .common import setup_ozw @@ -53,6 +54,7 @@ async def test_publish_without_mqtt(hass, caplog): # Sending a message should not error with the MQTT integration not set up. send_message("test_topic", "test_payload") + await hass.async_block_till_done() assert "MQTT integration is not set up" in caplog.text @@ -75,14 +77,21 @@ async def test_unload_entry(hass, generic_data, switch_msg, caplog): await hass.config_entries.async_unload(entry.entry_id) assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED - assert len(hass.states.async_entity_ids("switch")) == 0 + entities = hass.states.async_entity_ids("switch") + assert len(entities) == 1 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE + assert hass.states.get(entity).attributes.get(ATTR_RESTORED) # Send a message for a switch from the broker to check that # all entity topic subscribers are unsubscribed. receive_message(switch_msg) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids("switch")) == 0 + assert len(hass.states.async_entity_ids("switch")) == 1 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE + assert hass.states.get(entity).attributes.get(ATTR_RESTORED) # Load the integration again and check that there are no errors when # adding the entities. @@ -127,8 +136,8 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) - stop_addon.call_count == 1 - uninstall_addon.call_count == 1 + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 1 assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() @@ -141,8 +150,8 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) - stop_addon.call_count == 1 - uninstall_addon.call_count == 0 + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 0 assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the OpenZWave add-on" in caplog.text @@ -157,8 +166,8 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) - stop_addon.call_count == 1 - uninstall_addon.call_count == 1 + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 1 assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the OpenZWave add-on" in caplog.text diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py index 8f95043f4fa..5c9bf183c6f 100644 --- a/tests/components/panasonic_viera/test_init.py +++ b/tests/components/panasonic_viera/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.panasonic_viera.const import ( DOMAIN, ) from homeassistant.config_entries import ENTRY_STATE_NOT_LOADED -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -253,9 +253,11 @@ async def test_setup_unload_entry(hass): await hass.async_block_till_done() await hass.config_entries.async_unload(mock_entry.entry_id) - assert mock_entry.state == ENTRY_STATE_NOT_LOADED - state = hass.states.get("media_player.panasonic_viera_tv") + assert state.state == STATE_UNAVAILABLE + await hass.config_entries.async_remove(mock_entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("media_player.panasonic_viera_tv") assert state is None diff --git a/tests/components/person/test_significant_change.py b/tests/components/person/test_significant_change.py new file mode 100644 index 00000000000..1b4f6940e90 --- /dev/null +++ b/tests/components/person/test_significant_change.py @@ -0,0 +1,16 @@ +"""Test the Person significant change platform.""" +from homeassistant.components.person.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change(): + """Detect Person significant changes and ensure that attribute changes do not trigger a significant change.""" + old_attrs = {"source": "device_tracker.wifi_device"} + new_attrs = {"source": "device_tracker.gps_device"} + assert not async_check_significant_change( + None, "home", old_attrs, "home", new_attrs + ) + assert async_check_significant_change( + None, "home", new_attrs, "not_home", new_attrs + ) diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py new file mode 100644 index 00000000000..9dea390a600 --- /dev/null +++ b/tests/components/philips_js/__init__.py @@ -0,0 +1,77 @@ +"""Tests for the Philips TV integration.""" + +MOCK_SERIAL_NO = "1234567890" +MOCK_NAME = "Philips TV" + +MOCK_USERNAME = "mock_user" +MOCK_PASSWORD = "mock_password" + +MOCK_SYSTEM = { + "menulanguage": "English", + "name": MOCK_NAME, + "country": "Sweden", + "serialnumber": MOCK_SERIAL_NO, + "softwareversion": "abcd", + "model": "modelname", +} + +MOCK_SYSTEM_UNPAIRED = { + "menulanguage": "Dutch", + "name": "55PUS7181/12", + "country": "Netherlands", + "serialnumber": "ABCDEFGHIJKLF", + "softwareversion": "TPM191E_R.101.001.208.001", + "model": "65OLED855/12", + "deviceid": "1234567890", + "nettvversion": "6.0.2", + "epgsource": "one", + "api_version": {"Major": 6, "Minor": 2, "Patch": 0}, + "featuring": { + "jsonfeatures": { + "editfavorites": ["TVChannels", "SatChannels"], + "recordings": ["List", "Schedule", "Manage"], + "ambilight": ["LoungeLight", "Hue", "Ambilight"], + "menuitems": ["Setup_Menu"], + "textentry": [ + "context_based", + "initial_string_available", + "editor_info_available", + ], + "applications": ["TV_Apps", "TV_Games", "TV_Settings"], + "pointer": ["not_available"], + "inputkey": ["key"], + "activities": ["intent"], + "channels": ["preset_string"], + "mappings": ["server_mapping"], + }, + "systemfeatures": { + "tvtype": "consumer", + "content": ["dmr", "dms_tad"], + "tvsearch": "intent", + "pairing_type": "digest_auth_pairing", + "secured_transport": "True", + }, + }, +} + +MOCK_USERINPUT = { + "host": "1.1.1.1", +} + +MOCK_IMPORT = {"host": "1.1.1.1", "api_version": 6} + +MOCK_CONFIG = { + "host": "1.1.1.1", + "api_version": 1, + "system": MOCK_SYSTEM, +} + +MOCK_CONFIG_PAIRED = { + "host": "1.1.1.1", + "api_version": 6, + "username": MOCK_USERNAME, + "password": MOCK_PASSWORD, + "system": MOCK_SYSTEM_UNPAIRED, +} + +MOCK_ENTITY_ID = "media_player.philips_tv" diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py new file mode 100644 index 00000000000..4b6150f9f81 --- /dev/null +++ b/tests/components/philips_js/conftest.py @@ -0,0 +1,71 @@ +"""Standard setup for tests.""" +from unittest.mock import create_autospec, patch + +from haphilipsjs import PhilipsTV +from pytest import fixture + +from homeassistant import setup +from homeassistant.components.philips_js.const import DOMAIN + +from . import MOCK_CONFIG, MOCK_ENTITY_ID, MOCK_NAME, MOCK_SERIAL_NO, MOCK_SYSTEM + +from tests.common import MockConfigEntry, mock_device_registry + + +@fixture(autouse=True) +async def setup_notification(hass): + """Configure notification system.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + +@fixture(autouse=True) +def mock_tv(): + """Disable component actual use.""" + tv = create_autospec(PhilipsTV) + tv.sources = {} + tv.channels = {} + tv.application = None + tv.applications = {} + tv.system = MOCK_SYSTEM + tv.api_version = 1 + tv.api_version_detected = None + tv.on = True + tv.notify_change_supported = False + tv.pairing_type = None + tv.powerstate = None + + with patch( + "homeassistant.components.philips_js.config_flow.PhilipsTV", return_value=tv + ), patch("homeassistant.components.philips_js.PhilipsTV", return_value=tv): + yield tv + + +@fixture +async def mock_config_entry(hass): + """Get standard player.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, title=MOCK_NAME) + config_entry.add_to_hass(hass) + return config_entry + + +@fixture +def mock_device_reg(hass): + """Get standard device.""" + return mock_device_registry(hass) + + +@fixture +async def mock_entity(hass, mock_device_reg, mock_config_entry): + """Get standard player.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return MOCK_ENTITY_ID + + +@fixture +def mock_device(hass, mock_device_reg, mock_entity, mock_config_entry): + """Get standard device.""" + return mock_device_reg.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, MOCK_SERIAL_NO)}, + ) diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py new file mode 100644 index 00000000000..45e896319f1 --- /dev/null +++ b/tests/components/philips_js/test_config_flow.py @@ -0,0 +1,241 @@ +"""Test the Philips TV config flow.""" +from unittest.mock import ANY, patch + +from haphilipsjs import PairingFailure +from pytest import fixture + +from homeassistant import config_entries +from homeassistant.components.philips_js.const import DOMAIN + +from . import ( + MOCK_CONFIG, + MOCK_CONFIG_PAIRED, + MOCK_IMPORT, + MOCK_PASSWORD, + MOCK_SYSTEM_UNPAIRED, + MOCK_USERINPUT, + MOCK_USERNAME, +) + + +@fixture(autouse=True) +def mock_setup(): + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup", return_value=True + ) as mock_setup: + yield mock_setup + + +@fixture(autouse=True) +def mock_setup_entry(): + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@fixture +async def mock_tv_pairable(mock_tv): + """Return a mock tv that is pariable.""" + mock_tv.system = MOCK_SYSTEM_UNPAIRED + mock_tv.pairing_type = "digest_auth_pairing" + mock_tv.api_version = 6 + mock_tv.api_version_detected = 6 + mock_tv.secured_transport = True + + mock_tv.pairRequest.return_value = {} + mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD + return mock_tv + + +async def test_import(hass, mock_setup, mock_setup_entry): + """Test we get an item on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "Philips TV (1234567890)" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_exist(hass, mock_config_entry): + """Test we get an item on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form(hass, mock_setup, mock_setup_entry): + """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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Philips TV (1234567890)" + assert result2["data"] == MOCK_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass, mock_tv): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_tv.system = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USERINPUT + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_error(hass, mock_tv): + """Test we handle unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_tv.getSystem.side_effect = Exception("Unexpected exception") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USERINPUT + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} + + +async def test_pairing(hass, mock_tv_pairable, mock_setup, mock_setup_entry): + """Test we get the form.""" + mock_tv = mock_tv_pairable + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + + mock_tv.setTransport.assert_called_with(True) + mock_tv.pairRequest.assert_called() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": "1234"} + ) + + assert result == { + "flow_id": ANY, + "type": "create_entry", + "description": None, + "description_placeholders": None, + "handler": "philips_js", + "result": ANY, + "title": "55PUS7181/12 (ABCDEFGHIJKLF)", + "data": MOCK_CONFIG_PAIRED, + "version": 1, + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_pair_request_failed( + hass, mock_tv_pairable, mock_setup, mock_setup_entry +): + """Test we get the form.""" + mock_tv = mock_tv_pairable + mock_tv.pairRequest.side_effect = PairingFailure({}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + + assert result == { + "flow_id": ANY, + "description_placeholders": {"error_id": None}, + "handler": "philips_js", + "reason": "pairing_failure", + "type": "abort", + } + + +async def test_pair_grant_failed(hass, mock_tv_pairable, mock_setup, mock_setup_entry): + """Test we get the form.""" + mock_tv = mock_tv_pairable + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_tv.setTransport.assert_called_with(True) + mock_tv.pairRequest.assert_called() + + # Test with invalid pin + mock_tv.pairGrant.side_effect = PairingFailure({"error_id": "INVALID_PIN"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": "1234"} + ) + + assert result["type"] == "form" + assert result["errors"] == {"pin": "invalid_pin"} + + # Test with unexpected failure + mock_tv.pairGrant.side_effect = PairingFailure({}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": "1234"} + ) + + assert result == { + "flow_id": ANY, + "description_placeholders": {"error_id": None}, + "handler": "philips_js", + "reason": "pairing_failure", + "type": "abort", + } diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py new file mode 100644 index 00000000000..ebda40f13e5 --- /dev/null +++ b/tests/components/philips_js/test_device_trigger.py @@ -0,0 +1,73 @@ +"""The tests for Philips TV device triggers.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.philips_js.const import DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import ( + assert_lists_same, + async_get_device_automations, + async_mock_service, +) +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa + + +@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_device): + """Test we get the expected triggers.""" + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "turn_on", + "device_id": mock_device.id, + }, + ] + triggers = await async_get_device_automations(hass, "trigger", mock_device.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_turn_on_request( + hass, calls, mock_tv, mock_entity, mock_device +): + """Test for turn_on and turn_off triggers firing.""" + + mock_tv.on = False + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": mock_device.id, + "type": "turn_on", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "{{ trigger.device_id }}"}, + }, + } + ] + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": mock_entity}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == mock_device.id diff --git a/tests/components/plaato/__init__.py b/tests/components/plaato/__init__.py new file mode 100644 index 00000000000..dac4d341790 --- /dev/null +++ b/tests/components/plaato/__init__.py @@ -0,0 +1 @@ +"""Tests for the Plaato integration.""" diff --git a/tests/components/plaato/test_config_flow.py b/tests/components/plaato/test_config_flow.py new file mode 100644 index 00000000000..7966882a977 --- /dev/null +++ b/tests/components/plaato/test_config_flow.py @@ -0,0 +1,309 @@ +"""Test the Plaato config flow.""" +from unittest.mock import patch + +from pyplaato.models.device import PlaatoDeviceType +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.plaato.const import ( + CONF_DEVICE_NAME, + CONF_DEVICE_TYPE, + CONF_USE_WEBHOOK, + DOMAIN, +) +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + +BASE_URL = "http://example.com" +WEBHOOK_ID = "webhook_id" +UNIQUE_ID = "plaato_unique_id" + + +@pytest.fixture(name="webhook_id") +def mock_webhook_id(): + """Mock webhook_id.""" + with patch( + "homeassistant.components.webhook.async_generate_id", return_value=WEBHOOK_ID + ), patch( + "homeassistant.components.webhook.async_generate_url", return_value="hook_id" + ): + yield + + +async def test_show_config_form(hass): + """Test show configuration 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"] == "form" + assert result["step_id"] == "user" + + +async def test_show_config_form_device_type_airlock(hass): + """Test show configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + assert result["data_schema"].schema.get(CONF_TOKEN) == str + assert result["data_schema"].schema.get(CONF_USE_WEBHOOK) == bool + + +async def test_show_config_form_device_type_keg(hass): + """Test show configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, CONF_DEVICE_NAME: "device_name"}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + assert result["data_schema"].schema.get(CONF_TOKEN) == str + assert result["data_schema"].schema.get(CONF_USE_WEBHOOK) is None + + +async def test_show_config_form_validate_webhook(hass, webhook_id): + """Test show configuration form.""" + + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + hass.config.components.add("cloud") + with patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "", + CONF_USE_WEBHOOK: True, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "webhook" + + +async def test_show_config_form_validate_token(hass): + """Test show configuration form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + with patch("homeassistant.components.plaato.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: "valid_token"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == PlaatoDeviceType.Keg.name + assert result["data"] == { + CONF_USE_WEBHOOK: False, + CONF_TOKEN: "valid_token", + CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, + CONF_DEVICE_NAME: "device_name", + } + + +async def test_show_config_form_no_cloud_webhook(hass, webhook_id): + """Test show configuration form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USE_WEBHOOK: True, + CONF_TOKEN: "", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "webhook" + assert result["errors"] is None + + +async def test_show_config_form_api_method_no_auth_token(hass, webhook_id): + """Test show configuration form.""" + + # Using Keg + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: ""} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + assert len(result["errors"]) == 1 + assert result["errors"]["base"] == "no_auth_token" + + # Using Airlock + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: ""} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + assert len(result["errors"]) == 1 + assert result["errors"]["base"] == "no_api_method" + + +async def test_options(hass): + """Test updating options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NAME", + data={}, + options={CONF_SCAN_INTERVAL: 5}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.plaato.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.plaato.async_setup_entry", return_value=True + ) as mock_setup_entry: + + 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"] == "user" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SCAN_INTERVAL: 10}, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_SCAN_INTERVAL] == 10 + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_webhook(hass, webhook_id): + """Test updating options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NAME", + data={CONF_USE_WEBHOOK: True, CONF_WEBHOOK_ID: None}, + options={CONF_SCAN_INTERVAL: 5}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.plaato.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.plaato.async_setup_entry", return_value=True + ) as mock_setup_entry: + + 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"] == "webhook" + assert result["description_placeholders"] == {"webhook_url": ""} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_WEBHOOK_ID: WEBHOOK_ID}, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_WEBHOOK_ID] == CONF_WEBHOOK_ID + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index d3e66cc4989..372a06f15b6 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -218,6 +218,12 @@ def plextv_resources_fixture(plextv_resources_base): return plextv_resources_base.format(second_server_enabled=0) +@pytest.fixture(name="plextv_shared_users", scope="session") +def plextv_shared_users_fixture(plextv_resources_base): + """Load payload for plex.tv shared users and return it.""" + return load_fixture("plex/plextv_shared_users.xml") + + @pytest.fixture(name="session_base", scope="session") def session_base_fixture(): """Load the base session payload and return it.""" @@ -293,6 +299,7 @@ def mock_plex_calls( children_200, children_300, empty_library, + empty_payload, grandchildren_300, library, library_sections, @@ -310,12 +317,15 @@ def mock_plex_calls( playlist_500, plextv_account, plextv_resources, + plextv_shared_users, plex_server_accounts, plex_server_clients, plex_server_default, security_token, ): """Mock Plex API calls.""" + requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) + requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index bc0e59e658f..bdd78131800 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -203,7 +203,7 @@ async def test_single_available_server(hass, mock_plex_calls): server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - assert result["title"] == mock_plex_server.friendly_name + assert result["title"] == mock_plex_server.url_in_use assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( result["data"][CONF_SERVER_IDENTIFIER] @@ -259,7 +259,7 @@ async def test_multiple_servers_with_selection( server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - assert result["title"] == mock_plex_server.friendly_name + assert result["title"] == mock_plex_server.url_in_use assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( result["data"][CONF_SERVER_IDENTIFIER] @@ -317,7 +317,7 @@ async def test_adding_last_unconfigured_server( server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - assert result["title"] == mock_plex_server.friendly_name + assert result["title"] == mock_plex_server.url_in_use assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( result["data"][CONF_SERVER_IDENTIFIER] @@ -656,7 +656,7 @@ async def test_manual_config(hass, mock_plex_calls): server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - assert result["title"] == mock_plex_server.friendly_name + assert result["title"] == mock_plex_server.url_in_use assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use @@ -692,7 +692,7 @@ async def test_manual_config_with_token(hass, mock_plex_calls): server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - assert result["title"] == mock_plex_server.friendly_name + assert result["title"] == mock_plex_server.url_in_use assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 95d2ef9bddb..2e5a30ce11a 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -116,6 +116,7 @@ async def test_setup_when_certificate_changed( plex_server_default, plextv_account, plextv_resources, + plextv_shared_users, ): """Test setup component when the Plex certificate has changed.""" await async_setup_component(hass, "persistent_notification", {}) @@ -141,6 +142,9 @@ async def test_setup_when_certificate_changed( unique_id=DEFAULT_DATA["server_id"], ) + requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) + requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) + requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) requests_mock.get(old_url, exc=WrongCertHostnameException) diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py index 092d7e09008..fbd1205b2ef 100644 --- a/tests/components/plex/test_media_players.py +++ b/tests/components/plex/test_media_players.py @@ -22,7 +22,7 @@ async def test_plex_tv_clients( media_players_after = len(hass.states.async_entity_ids("media_player")) assert media_players_after == media_players_before + 1 - await hass.config_entries.async_unload(entry.entry_id) + await hass.config_entries.async_remove(entry.entry_id) # Ensure only plex.tv resource client is found with patch("plexapi.server.PlexServer.sessions", return_value=[]): diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 1d0e6dbbfa0..d2b731777e2 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -10,14 +10,27 @@ from tests.common import fire_time_changed def wait_recording_done(hass): """Block till recording is done.""" + hass.block_till_done() trigger_db_commit(hass) hass.block_till_done() hass.data[recorder.DATA_INSTANCE].block_till_done() hass.block_till_done() +async def async_wait_recording_done(hass): + """Block till recording is done.""" + await hass.loop.run_in_executor(None, wait_recording_done, hass) + + def trigger_db_commit(hass): """Force the recorder to commit.""" for _ in range(recorder.DEFAULT_COMMIT_INTERVAL): # We only commit on time change fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + + +def corrupt_db_file(test_db_file): + """Corrupt an sqlite3 database file.""" + with open(test_db_file, "w+") as fhandle: + fhandle.seek(200) + fhandle.write("I am a corrupt db" * 100) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index d4092d709c0..63f4b9887c6 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -6,24 +6,60 @@ from unittest.mock import patch from sqlalchemy.exc import OperationalError from homeassistant.components.recorder import ( + CONF_DB_URL, CONFIG_SCHEMA, + DATA_INSTANCE, DOMAIN, + SERVICE_DISABLE, + SERVICE_ENABLE, + SERVICE_PURGE, + SQLITE_URL_PREFIX, Recorder, run_information, run_information_from_instance, run_information_with_session, ) -from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import Events, RecorderRuns, States from homeassistant.components.recorder.util import session_scope -from homeassistant.const import MATCH_ALL, STATE_LOCKED, STATE_UNLOCKED -from homeassistant.core import Context, callback -from homeassistant.setup import async_setup_component +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + MATCH_ALL, + STATE_LOCKED, + STATE_UNLOCKED, +) +from homeassistant.core import Context, CoreState, callback +from homeassistant.setup import async_setup_component, setup_component from homeassistant.util import dt as dt_util -from .common import wait_recording_done +from .common import async_wait_recording_done, corrupt_db_file, wait_recording_done -from tests.common import fire_time_changed, get_test_home_assistant +from tests.common import ( + async_init_recorder_component, + fire_time_changed, + get_test_home_assistant, +) + + +async def test_shutdown_before_startup_finishes(hass): + """Test shutdown before recorder starts is clean.""" + + hass.state = CoreState.not_running + + await async_init_recorder_component(hass) + await hass.async_block_till_done() + + session = await hass.async_add_executor_job(hass.data[DATA_INSTANCE].get_session) + + with patch.object(hass.data[DATA_INSTANCE], "engine"): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + await hass.async_stop() + + run_info = await hass.async_add_executor_job(run_information_with_session, session) + + assert run_info.run_id == 1 + assert run_info.start is not None + assert run_info.end is not None def test_saving_state(hass, hass_recorder): @@ -486,5 +522,203 @@ def test_run_information(hass_recorder): assert run_info.closed_incorrect is False +def test_has_services(hass_recorder): + """Test the services exist.""" + hass = hass_recorder() + + assert hass.services.has_service(DOMAIN, SERVICE_DISABLE) + assert hass.services.has_service(DOMAIN, SERVICE_ENABLE) + assert hass.services.has_service(DOMAIN, SERVICE_PURGE) + + +def test_service_disable_events_not_recording(hass, hass_recorder): + """Test that events are not recorded when recorder is disabled using service.""" + hass = hass_recorder() + + assert hass.services.call( + DOMAIN, + SERVICE_DISABLE, + {}, + blocking=True, + ) + + event_type = "EVENT_TEST" + + events = [] + + @callback + def event_listener(event): + """Record events from eventbus.""" + if event.event_type == event_type: + events.append(event) + + hass.bus.listen(MATCH_ALL, event_listener) + + event_data1 = {"test_attr": 5, "test_attr_10": "nice"} + hass.bus.fire(event_type, event_data1) + wait_recording_done(hass) + + assert len(events) == 1 + event = events[0] + + 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 hass.services.call( + DOMAIN, + SERVICE_ENABLE, + {}, + blocking=True, + ) + + event_data2 = {"attr_one": 5, "attr_two": "nice"} + hass.bus.fire(event_type, event_data2) + wait_recording_done(hass) + + assert len(events) == 2 + assert events[0] != events[1] + assert events[0].data != events[1].data + + with session_scope(hass=hass) as session: + db_events = list(session.query(Events).filter_by(event_type=event_type)) + assert len(db_events) == 1 + db_event = db_events[0].to_native() + + event = events[1] + + assert event.event_type == db_event.event_type + assert event.data == db_event.data + assert event.origin == db_event.origin + assert event.time_fired.replace(microsecond=0) == db_event.time_fired.replace( + microsecond=0 + ) + + +def test_service_disable_states_not_recording(hass, hass_recorder): + """Test that state changes are not recorded when recorder is disabled using service.""" + hass = hass_recorder() + + assert hass.services.call( + DOMAIN, + SERVICE_DISABLE, + {}, + blocking=True, + ) + + hass.states.set("test.one", "on", {}) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + assert len(list(session.query(States))) == 0 + + assert hass.services.call( + DOMAIN, + SERVICE_ENABLE, + {}, + blocking=True, + ) + + hass.states.set("test.two", "off", {}) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + db_states = list(session.query(States)) + assert len(db_states) == 1 + assert db_states[0].event_id > 0 + assert db_states[0].to_native() == _state_empty_context(hass, "test.two") + + +def test_service_disable_run_information_recorded(tmpdir): + """Test that runs are still recorded when recorder is disabled.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + db_run_info = list(session.query(RecorderRuns)) + assert len(db_run_info) == 1 + assert db_run_info[0].start is not None + assert db_run_info[0].end is None + + assert hass.services.call( + DOMAIN, + SERVICE_DISABLE, + {}, + blocking=True, + ) + + wait_recording_done(hass) + hass.stop() + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + db_run_info = list(session.query(RecorderRuns)) + assert len(db_run_info) == 2 + assert db_run_info[0].start is not None + assert db_run_info[0].end is not None + assert db_run_info[1].start is not None + assert db_run_info[1].end is None + + hass.stop() + + class CannotSerializeMe: """A class that the JSONEncoder cannot serialize.""" + + +async def test_database_corruption_while_running(hass, tmpdir, caplog): + """Test we can recover from sqlite3 db corruption.""" + + def _create_tmpdir_for_test_db(): + return tmpdir.mkdir("sqlite").join("test.db") + + test_db_file = await hass.async_add_executor_job(_create_tmpdir_for_test_db) + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + await hass.async_block_till_done() + caplog.clear() + + hass.states.async_set("test.lost", "on", {}) + + await async_wait_recording_done(hass) + await hass.async_add_executor_job(corrupt_db_file, test_db_file) + await async_wait_recording_done(hass) + + # This state will not be recorded because + # the database corruption will be discovered + # and we will have to rollback to recover + hass.states.async_set("test.one", "off", {}) + await async_wait_recording_done(hass) + + assert "Unrecoverable sqlite3 database corruption detected" in caplog.text + assert "The system will rename the corrupt database file" in caplog.text + assert "Connected to recorder database" in caplog.text + + # This state should go into the new database + hass.states.async_set("test.two", "on", {}) + await async_wait_recording_done(hass) + + def _get_last_state(): + with session_scope(hass=hass) as session: + db_states = list(session.query(States)) + assert len(db_states) == 1 + assert db_states[0].event_id > 0 + return db_states[0].to_native() + + state = await hass.async_add_executor_job(_get_last_state) + assert state.entity_id == "test.two" + assert state.state == "on" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + hass.stop() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index a4109648d2f..4aba6569a41 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -8,11 +8,16 @@ import pytest from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.util import dt as dt_util -from .common import wait_recording_done +from .common import corrupt_db_file -from tests.common import get_test_home_assistant, init_recorder_component +from tests.common import ( + async_init_recorder_component, + get_test_home_assistant, + init_recorder_component, +) @pytest.fixture @@ -90,7 +95,7 @@ def test_validate_or_move_away_sqlite_database_with_integrity_check( util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False ) - _corrupt_db_file(test_db_file) + corrupt_db_file(test_db_file) assert util.validate_sqlite_database(dburl, db_integrity_check) is False @@ -127,7 +132,7 @@ def test_validate_or_move_away_sqlite_database_without_integrity_check( util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False ) - _corrupt_db_file(test_db_file) + corrupt_db_file(test_db_file) assert util.validate_sqlite_database(dburl, db_integrity_check) is False @@ -142,18 +147,25 @@ def test_validate_or_move_away_sqlite_database_without_integrity_check( assert util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is True -def test_last_run_was_recently_clean(hass_recorder): +async def test_last_run_was_recently_clean(hass): """Test we can check if the last recorder run was recently clean.""" - hass = hass_recorder() + await async_init_recorder_component(hass) + await hass.async_block_till_done() cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor() - assert util.last_run_was_recently_clean(cursor) is False + assert ( + await hass.async_add_executor_job(util.last_run_was_recently_clean, cursor) + is False + ) - hass.data[DATA_INSTANCE]._close_run() - wait_recording_done(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() - assert util.last_run_was_recently_clean(cursor) is True + assert ( + await hass.async_add_executor_job(util.last_run_was_recently_clean, cursor) + is True + ) thirty_min_future_time = dt_util.utcnow() + timedelta(minutes=30) @@ -161,7 +173,10 @@ def test_last_run_was_recently_clean(hass_recorder): "homeassistant.components.recorder.dt_util.utcnow", return_value=thirty_min_future_time, ): - assert util.last_run_was_recently_clean(cursor) is False + assert ( + await hass.async_add_executor_job(util.last_run_was_recently_clean, cursor) + is False + ) def test_basic_sanity_check(hass_recorder): @@ -178,40 +193,69 @@ def test_basic_sanity_check(hass_recorder): util.basic_sanity_check(cursor) -def test_combined_checks(hass_recorder): +def test_combined_checks(hass_recorder, caplog): """Run Checks on the open database.""" hass = hass_recorder() - db_integrity_check = False - cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor() - assert ( - util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check) is None - ) + assert util.run_checks_on_open_db("fake_db_path", cursor, False) is None + assert "skipped because db_integrity_check was disabled" in caplog.text + + caplog.clear() + assert util.run_checks_on_open_db("fake_db_path", cursor, True) is None + assert "could not validate that the sqlite3 database" in caplog.text + + # We are patching recorder.util here in order + # to avoid creating the full database on disk + with patch( + "homeassistant.components.recorder.util.basic_sanity_check", return_value=False + ): + caplog.clear() + assert util.run_checks_on_open_db("fake_db_path", cursor, False) is None + assert "skipped because db_integrity_check was disabled" in caplog.text + + caplog.clear() + assert util.run_checks_on_open_db("fake_db_path", cursor, True) is None + assert "could not validate that the sqlite3 database" in caplog.text # We are patching recorder.util here in order # to avoid creating the full database on disk with patch("homeassistant.components.recorder.util.last_run_was_recently_clean"): + caplog.clear() + assert util.run_checks_on_open_db("fake_db_path", cursor, False) is None assert ( - util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check) - is None + "system was restarted cleanly and passed the basic sanity check" + in caplog.text ) + caplog.clear() + assert util.run_checks_on_open_db("fake_db_path", cursor, True) is None + assert ( + "system was restarted cleanly and passed the basic sanity check" + in caplog.text + ) + + caplog.clear() with patch( "homeassistant.components.recorder.util.last_run_was_recently_clean", side_effect=sqlite3.DatabaseError, ), pytest.raises(sqlite3.DatabaseError): - util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check) + util.run_checks_on_open_db("fake_db_path", cursor, False) + + caplog.clear() + with patch( + "homeassistant.components.recorder.util.last_run_was_recently_clean", + side_effect=sqlite3.DatabaseError, + ), pytest.raises(sqlite3.DatabaseError): + util.run_checks_on_open_db("fake_db_path", cursor, True) cursor.execute("DROP TABLE events;") + caplog.clear() with pytest.raises(sqlite3.DatabaseError): - util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check) + util.run_checks_on_open_db("fake_db_path", cursor, False) - -def _corrupt_db_file(test_db_file): - """Corrupt an sqlite3 database file.""" - f = open(test_db_file, "a") - f.write("I am a corrupt db") - f.close() + caplog.clear() + with pytest.raises(sqlite3.DatabaseError): + util.run_checks_on_open_db("fake_db_path", cursor, True) diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py new file mode 100644 index 00000000000..19a5651e989 --- /dev/null +++ b/tests/components/rest/test_init.py @@ -0,0 +1,340 @@ +"""Tests for rest component.""" + +import asyncio +from datetime import timedelta +from os import path +from unittest.mock import patch + +import respx + +from homeassistant import config as hass_config +from homeassistant.components.rest.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + DATA_MEGABYTES, + SERVICE_RELOAD, + STATE_UNAVAILABLE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +@respx.mock +async def test_setup_with_endpoint_timeout_with_recovery(hass): + """Test setup with an endpoint that times out that recovers.""" + await async_setup_component(hass, "homeassistant", {}) + + respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + + # Refresh the coordinator + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + # Wait for platform setup retry + async_fire_time_changed(hass, utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + # Now the end point flakes out again + respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) + + # Refresh the coordinator + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.sensor2").state == STATE_UNAVAILABLE + assert hass.states.get("binary_sensor.binary_sensor1").state == STATE_UNAVAILABLE + assert hass.states.get("binary_sensor.binary_sensor2").state == STATE_UNAVAILABLE + + # We request a manual refresh when the + # endpoint is working again + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.sensor1"]}, + blocking=True, + ) + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + +@respx.mock +async def test_setup_minimum_resource_template(hass): + """Test setup with minimum configuration (resource_template).""" + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource_template": "{% set url = 'http://localhost' %}{{ url }}", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + +@respx.mock +async def test_reload(hass): + """Verify we can reload.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + assert hass.states.get("sensor.mockrest") + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_top_level.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mockreset") is None + assert hass.states.get("sensor.rollout") + assert hass.states.get("sensor.fallover") + + +@respx.mock +async def test_reload_and_remove_all(hass): + """Verify we can reload and remove all.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + assert hass.states.get("sensor.mockrest") + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_empty.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mockreset") is None + + +@respx.mock +async def test_reload_fails_to_read_configuration(hass): + """Verify reload when configuration is missing or broken.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_invalid.notyaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_notify.py b/tests/components/rest/test_notify.py index aa3e40c2dd4..fb7b8a31238 100644 --- a/tests/components/rest/test_notify.py +++ b/tests/components/rest/test_notify.py @@ -2,6 +2,8 @@ from os import path from unittest.mock import patch +import respx + from homeassistant import config as hass_config import homeassistant.components.notify as notify from homeassistant.components.rest import DOMAIN @@ -9,8 +11,10 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +@respx.mock async def test_reload_notify(hass): """Verify we can reload the notify service.""" + respx.get("http://localhost") % 200 assert await async_setup_component( hass, diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 58309cd7532..2e308f69384 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -91,6 +91,38 @@ async def test_setup_minimum(hass): assert len(hass.states.async_all()) == 1 +@respx.mock +async def test_manual_update(hass): + """Test setup with minimum configuration.""" + await async_setup_component(hass, "homeassistant", {}) + respx.get("http://localhost").respond(status_code=200, json={"data": "first"}) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "name": "mysensor", + "value_template": "{{ value_json.data }}", + "platform": "rest", + "resource_template": "{% set url = 'http://localhost' %}{{ url }}", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + assert hass.states.get("sensor.mysensor").state == "first" + + respx.get("http://localhost").respond(status_code=200, json={"data": "second"}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.mysensor"]}, + blocking=True, + ) + assert hass.states.get("sensor.mysensor").state == "second" + + @respx.mock async def test_setup_minimum_resource_template(hass): """Test setup with minimum configuration (resource_template).""" diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 5e0c9fbeab3..7141a34203a 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -3,6 +3,7 @@ import asyncio import aiohttp +from homeassistant.components.rest import DOMAIN import homeassistant.components.rest.switch as rest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -34,14 +35,14 @@ PARAMS = None async def test_setup_missing_config(hass): """Test setup with configuration missing required entries.""" - assert not await rest.async_setup_platform(hass, {CONF_PLATFORM: rest.DOMAIN}, None) + assert not await rest.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None) async def test_setup_missing_schema(hass): """Test setup with resource missing schema.""" assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "localhost"}, None, ) @@ -51,7 +52,7 @@ async def test_setup_failed_connect(hass, aioclient_mock): aioclient_mock.get("http://localhost", exc=aiohttp.ClientError) assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost"}, None, ) @@ -61,7 +62,7 @@ async def test_setup_timeout(hass, aioclient_mock): aioclient_mock.get("http://localhost", exc=asyncio.TimeoutError()) assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost"}, None, ) @@ -75,11 +76,12 @@ async def test_setup_minimum(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 @@ -92,12 +94,14 @@ async def test_setup_query_params(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", CONF_PARAMS: {"search": "something"}, } }, ) + await hass.async_block_till_done() + print(aioclient_mock) assert aioclient_mock.call_count == 1 @@ -110,7 +114,7 @@ async def test_setup(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", CONF_HEADERS: {"Content-type": CONTENT_TYPE_JSON}, @@ -119,6 +123,7 @@ async def test_setup(hass, aioclient_mock): } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) @@ -132,7 +137,7 @@ async def test_setup_with_state_resource(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", rest.CONF_STATE_RESOURCE: "http://localhost/state", @@ -142,6 +147,7 @@ async def test_setup_with_state_resource(hass, aioclient_mock): } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) diff --git a/tests/components/rituals_perfume_genie/__init__.py b/tests/components/rituals_perfume_genie/__init__.py new file mode 100644 index 00000000000..bd90242f14c --- /dev/null +++ b/tests/components/rituals_perfume_genie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rituals Perfume Genie integration.""" diff --git a/tests/components/rituals_perfume_genie/test_config_flow.py b/tests/components/rituals_perfume_genie/test_config_flow.py new file mode 100644 index 00000000000..92c3e15c247 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Rituals Perfume Genie config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientResponseError +from pyrituals import AuthenticationException + +from homeassistant import config_entries +from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +TEST_EMAIL = "rituals@example.com" +VALID_PASSWORD = "passw0rd" +WRONG_PASSWORD = "wrong-passw0rd" + + +def _mock_account(*_): + account = MagicMock() + account.authenticate = AsyncMock() + account.data = {CONF_EMAIL: TEST_EMAIL, ACCOUNT_HASH: "any"} + return account + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account", + side_effect=_mock_account, + ), patch( + "homeassistant.components.rituals_perfume_genie.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.rituals_perfume_genie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert isinstance(result2["data"][ACCOUNT_HASH], str) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: WRONG_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_auth_exception(hass): + """Test we handle auth exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=ClientResponseError(None, None, status=500), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 1a1b46117bd..09124ecdf37 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -65,14 +65,18 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -from tests.components.roku import UPNP_SERIAL, setup_integration +from tests.components.roku import NAME_ROKUTV, UPNP_SERIAL, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3" TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv" TV_HOST = "192.168.1.161" +TV_LOCATION = "Living room" +TV_MANUFACTURER = "Onn" +TV_MODEL = "100005844" TV_SERIAL = "YN00H5555555" +TV_SW_VERSION = "9.2.0" async def test_setup( @@ -304,6 +308,29 @@ async def test_tv_attributes( assert state.attributes.get(ATTR_MEDIA_TITLE) == "Airwolf" +async def test_tv_device_registry( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test device registered for Roku TV in the device registry.""" + await setup_integration( + hass, + aioclient_mock, + device="rokutv", + app="tvinput-dtv", + host=TV_HOST, + unique_id=TV_SERIAL, + ) + + device_registry = await hass.helpers.device_registry.async_get_registry() + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TV_SERIAL)}) + + assert reg_device.model == TV_MODEL + assert reg_device.sw_version == TV_SW_VERSION + assert reg_device.manufacturer == TV_MANUFACTURER + assert reg_device.suggested_area == TV_LOCATION + assert reg_device.name == NAME_ROKUTV + + async def test_services( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index bf8e674950f..b597717e4a8 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -6,13 +6,8 @@ from roombapy.roomba import RoombaInfo from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS -from homeassistant.components.roomba.const import ( - CONF_BLID, - CONF_CONTINUOUS, - CONF_DELAY, - DOMAIN, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from tests.common import MockConfigEntry diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index c39b4597632..d5755ac3288 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -428,6 +428,7 @@ async def test_if_fires_on_state_change_with_for(hass, calls): assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN assert len(calls) == 0 + hass.states.async_set(sensor1.entity_id, 10) hass.states.async_set(sensor1.entity_id, 11) await hass.async_block_till_done() assert len(calls) == 0 @@ -437,5 +438,5 @@ async def test_if_fires_on_state_change_with_for(hass, calls): await hass.async_block_till_done() assert ( calls[0].data["some"] - == f"turn_off device - {sensor1.entity_id} - unknown - 11 - 0:00:05" + == f"turn_off device - {sensor1.entity_id} - 10 - 11 - 0:00:05" ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 804d5a75952..51659cf7736 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -10,10 +10,14 @@ from homeassistant.components.shelly.const import ( DOMAIN, EVENT_SHELLY_CLICK, ) -from homeassistant.core import callback as ha_callback from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_mock_service, mock_device_registry +from tests.common import ( + MockConfigEntry, + async_capture_events, + async_mock_service, + mock_device_registry, +) MOCK_SETTINGS = { "name": "Test name", @@ -81,9 +85,7 @@ def calls(hass): @pytest.fixture def events(hass): """Yield caught shelly_click events.""" - ha_events = [] - hass.bus.async_listen(EVENT_SHELLY_CLICK, ha_callback(ha_events.append)) - yield ha_events + return async_capture_events(hass, EVENT_SHELLY_CLICK) @pytest.fixture @@ -91,7 +93,11 @@ async def coap_wrapper(hass): """Setups a coap wrapper with mocked device.""" await async_setup_component(hass, "shelly", {}) - config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"sleep_period": 0, "model": "SHSW-25"}, + unique_id="12345678", + ) config_entry.add_to_hass(hass) device = Mock( @@ -99,6 +105,7 @@ async def coap_wrapper(hass): settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, update=AsyncMock(), + initialized=True, ) hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 60f899296f6..9dfbc19255b 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -13,7 +13,7 @@ from tests.common import MockConfigEntry MOCK_SETTINGS = { "name": "Test name", - "device": {"mac": "test-mac", "hostname": "test-host"}, + "device": {"mac": "test-mac", "hostname": "test-host", "type": "SHSW-1"}, } DISCOVERY_INFO = { "host": "1.1.1.1", @@ -57,6 +57,8 @@ async def test_form(hass): assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -101,6 +103,8 @@ async def test_title_without_name(hass): assert result2["title"] == "shelly1pm-12345" assert result2["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -149,6 +153,8 @@ async def test_form_auth(hass): assert result3["title"] == "Test name" assert result3["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, "username": "test username", "password": "test password", } @@ -369,11 +375,110 @@ async def test_zeroconf(hass): assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_sleeping_device(hass): + """Test sleeping device configuration via zeroconf.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "aioshelly.get_info", + return_value={ + "mac": "test-mac", + "type": "SHSW-1", + "auth": False, + "sleep_mode": True, + }, + ), patch( + "aioshelly.Device.create", + new=AsyncMock( + return_value=Mock( + settings={ + "name": "Test name", + "device": { + "mac": "test-mac", + "hostname": "test-host", + "type": "SHSW-1", + }, + "sleep_mode": {"period": 10, "unit": "m"}, + }, + ) + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"]["name"] == "shelly1pm-12345" + with patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.shelly.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Test name" + assert result2["data"] == { + "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 600, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error", + [ + (aiohttp.ClientResponseError(Mock(), (), status=400), "cannot_connect"), + (asyncio.TimeoutError, "cannot_connect"), + ], +) +async def test_zeroconf_sleeping_device_error(hass, error): + """Test sleeping device configuration via zeroconf with error.""" + exc = error + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "aioshelly.get_info", + return_value={ + "mac": "test-mac", + "type": "SHSW-1", + "auth": False, + "sleep_mode": True, + }, + ), patch( + "aioshelly.Device.create", + new=AsyncMock(side_effect=exc), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + @pytest.mark.parametrize( "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")] ) @@ -407,6 +512,36 @@ async def test_zeroconf_confirm_error(hass, error): assert result2["errors"] == {"base": base_error} +async def test_zeroconf_confirm_auth_error(hass): + """Test we get credentials form after an auth error when confirming discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "aioshelly.Device.create", + new=AsyncMock(side_effect=aioshelly.AuthRequired), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "credentials" + assert result2["errors"] == {} + + async def test_zeroconf_already_configured(hass): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -502,6 +637,8 @@ async def test_zeroconf_require_auth(hass): assert result3["title"] == "Test name" assert result3["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, "username": "test username", "password": "test password", } diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 0be4c70ef18..48482787f4d 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,5 +1,6 @@ """Test shopping list component.""" +from homeassistant.components.shopping_list.const import DOMAIN from homeassistant.components.websocket_api.const import ( ERR_INVALID_FORMAT, ERR_NOT_FOUND, @@ -19,6 +20,39 @@ async def test_add_item(hass, sl_setup): assert response.speech["plain"]["speech"] == "I've added beer to your shopping list" +async def test_update_list(hass, sl_setup): + """Test updating all list items.""" + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} + ) + + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} + ) + + # Update a single attribute, other attributes shouldn't change + await hass.data[DOMAIN].async_update_list({"complete": True}) + + beer = hass.data[DOMAIN].items[0] + assert beer["name"] == "beer" + assert beer["complete"] is True + + cheese = hass.data[DOMAIN].items[1] + assert cheese["name"] == "cheese" + assert cheese["complete"] is True + + # Update multiple attributes + await hass.data[DOMAIN].async_update_list({"name": "dupe", "complete": False}) + + beer = hass.data[DOMAIN].items[0] + assert beer["name"] == "dupe" + assert beer["complete"] is False + + cheese = hass.data[DOMAIN].items[1] + assert cheese["name"] == "dupe" + assert cheese["complete"] is False + + async def test_recent_items_intent(hass, sl_setup): """Test recent items.""" diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 6931b3dfbb5..e10d63a2e07 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.smartthings import binary_sensor from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -93,4 +93,7 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "binary_sensor") # Assert - assert not hass.states.get("binary_sensor.motion_sensor_1_motion") + assert ( + hass.states.get("binary_sensor.motion_sensor_1_motion").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 0483480cb8a..178c905208e 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( STATE_OPENING, ) from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -193,4 +193,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) # Assert - assert not hass.states.get("cover.garage") + assert hass.states.get("cover.garage").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 0ebef7e7323..1f837d58bf8 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -17,7 +17,11 @@ from homeassistant.components.fan import ( SUPPORT_SET_SPEED, ) from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -184,4 +188,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "fan") # Assert - assert not hass.states.get("fan.fan_1") + assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index bd9557c6b97..f6d7d8dd9f4 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -19,7 +19,11 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, ) from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -304,4 +308,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "light") # Assert - assert not hass.states.get("light.color_dimmer_2") + assert hass.states.get("light.color_dimmer_2").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 0492f2281ce..185eae22ccf 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -9,6 +9,7 @@ from pysmartthings.device import Status from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -104,4 +105,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "lock") # Assert - assert not hass.states.get("lock.lock_1") + assert hass.states.get("lock.lock_1").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index a9e6443d2bf..6ab4bc08080 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -5,7 +5,7 @@ The only mocking required is of the underlying SmartThings API object so real HTTP calls are not initiated during testing. """ from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE from .conftest import setup_platform @@ -46,4 +46,4 @@ async def test_unload_config_entry(hass, scene): # Act await hass.config_entries.async_forward_entry_unload(config_entry, SCENE_DOMAIN) # Assert - assert not hass.states.get("scene.test_scene") + assert hass.states.get("scene.test_scene").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 3faf0f621a3..53f4b2c7244 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -117,4 +118,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") # Assert - assert not hass.states.get("sensor.sensor_1_battery") + assert hass.states.get("sensor.sensor_1_battery").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 3ac86426eeb..27ed5050bee 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -12,6 +12,7 @@ from homeassistant.components.switch import ( ATTR_TODAY_ENERGY_KWH, DOMAIN as SWITCH_DOMAIN, ) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -96,4 +97,4 @@ async def test_unload_config_entry(hass, device_factory): # Act await hass.config_entries.async_forward_entry_unload(config_entry, "switch") # Assert - assert not hass.states.get("switch.switch_1") + assert hass.states.get("switch.switch_1").state == STATE_UNAVAILABLE diff --git a/tests/components/smarttub/__init__.py b/tests/components/smarttub/__init__.py new file mode 100644 index 00000000000..b19af1ee59a --- /dev/null +++ b/tests/components/smarttub/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the smarttub integration.""" + +from datetime import timedelta + +from homeassistant.components.smarttub.const import SCAN_INTERVAL +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def trigger_update(hass): + """Trigger a polling update by moving time forward.""" + new_time = dt.utcnow() + timedelta(seconds=SCAN_INTERVAL + 1) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py new file mode 100644 index 00000000000..b7c90b5ad3e --- /dev/null +++ b/tests/components/smarttub/conftest.py @@ -0,0 +1,139 @@ +"""Common fixtures for smarttub tests.""" + +from unittest.mock import create_autospec, patch + +import pytest +import smarttub + +from homeassistant.components.smarttub.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_data(): + """Provide configuration data for tests.""" + return {CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"} + + +@pytest.fixture +def config_entry(config_data): + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=config_data, + options={}, + ) + + +@pytest.fixture +async def setup_component(hass): + """Set up the component.""" + assert await async_setup_component(hass, DOMAIN, {}) is True + + +@pytest.fixture(name="spa") +def mock_spa(): + """Mock a smarttub.Spa.""" + + mock_spa = create_autospec(smarttub.Spa, instance=True) + mock_spa.id = "mockspa1" + mock_spa.brand = "mockbrand1" + mock_spa.model = "mockmodel1" + mock_spa.get_status.return_value = smarttub.SpaState( + mock_spa, + **{ + "setTemperature": 39, + "water": {"temperature": 38}, + "heater": "ON", + "online": True, + "heatMode": "AUTO", + "state": "NORMAL", + "primaryFiltration": { + "cycle": 1, + "duration": 4, + "lastUpdated": "2021-01-20T11:38:57.014Z", + "mode": "NORMAL", + "startHour": 2, + "status": "INACTIVE", + }, + "secondaryFiltration": { + "lastUpdated": "2020-07-09T19:39:52.961Z", + "mode": "AWAY", + "status": "INACTIVE", + }, + "flowSwitch": "OPEN", + "ozone": "OFF", + "uv": "OFF", + "blowoutCycle": "INACTIVE", + "cleanupCycle": "INACTIVE", + }, + ) + mock_circulation_pump = create_autospec(smarttub.SpaPump, instance=True) + mock_circulation_pump.id = "CP" + mock_circulation_pump.spa = mock_spa + mock_circulation_pump.state = smarttub.SpaPump.PumpState.OFF + mock_circulation_pump.type = smarttub.SpaPump.PumpType.CIRCULATION + + mock_jet_off = create_autospec(smarttub.SpaPump, instance=True) + mock_jet_off.id = "P1" + mock_jet_off.spa = mock_spa + mock_jet_off.state = smarttub.SpaPump.PumpState.OFF + mock_jet_off.type = smarttub.SpaPump.PumpType.JET + + mock_jet_on = create_autospec(smarttub.SpaPump, instance=True) + mock_jet_on.id = "P2" + mock_jet_on.spa = mock_spa + mock_jet_on.state = smarttub.SpaPump.PumpState.HIGH + mock_jet_on.type = smarttub.SpaPump.PumpType.JET + + mock_spa.get_pumps.return_value = [mock_circulation_pump, mock_jet_off, mock_jet_on] + + mock_light_off = create_autospec(smarttub.SpaLight, instance=True) + mock_light_off.spa = mock_spa + mock_light_off.zone = 1 + mock_light_off.intensity = 0 + mock_light_off.mode = smarttub.SpaLight.LightMode.OFF + + mock_light_on = create_autospec(smarttub.SpaLight, instance=True) + mock_light_on.spa = mock_spa + mock_light_on.zone = 2 + mock_light_on.intensity = 50 + mock_light_on.mode = smarttub.SpaLight.LightMode.PURPLE + + mock_spa.get_lights.return_value = [mock_light_off, mock_light_on] + + return mock_spa + + +@pytest.fixture(name="account") +def mock_account(spa): + """Mock a SmartTub.Account.""" + + mock_account = create_autospec(smarttub.Account, instance=True) + mock_account.id = "mockaccount1" + mock_account.get_spas.return_value = [spa] + return mock_account + + +@pytest.fixture(name="smarttub_api", autouse=True) +def mock_api(account, spa): + """Mock the SmartTub API.""" + + with patch( + "homeassistant.components.smarttub.controller.SmartTub", + autospec=True, + ) as api_class_mock: + api_mock = api_class_mock.return_value + api_mock.get_account.return_value = account + yield api_mock + + +@pytest.fixture +async def setup_entry(hass, config_entry): + """Initialize the config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py new file mode 100644 index 00000000000..b2624369e96 --- /dev/null +++ b/tests/components/smarttub/test_binary_sensor.py @@ -0,0 +1,13 @@ +"""Test the SmartTub binary sensor platform.""" + +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY, STATE_ON + + +async def test_binary_sensors(spa, setup_entry, hass): + """Test the binary sensors.""" + + entity_id = f"binary_sensor.{spa.brand}_{spa.model}_online" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get("device_class") == DEVICE_CLASS_CONNECTIVITY diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py new file mode 100644 index 00000000000..a034a4ce17e --- /dev/null +++ b/tests/components/smarttub/test_climate.py @@ -0,0 +1,95 @@ +"""Test the SmartTub climate platform.""" + +import smarttub + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_HEAT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.components.smarttub.const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, +) + +from . import trigger_update + + +async def test_thermostat_update(spa, setup_entry, hass): + """Test the thermostat entity.""" + + entity_id = f"climate.{spa.brand}_{spa.model}_thermostat" + state = hass.states.get(entity_id) + assert state + + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + spa.get_status.return_value.heater = "OFF" + await trigger_update(hass) + state = hass.states.get(entity_id) + + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + assert set(state.attributes[ATTR_HVAC_MODES]) == {HVAC_MODE_HEAT} + assert state.state == HVAC_MODE_HEAT + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + ) + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 38 + assert state.attributes[ATTR_TEMPERATURE] == 39 + assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP + assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day"] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 37}, + blocking=True, + ) + spa.set_temperature.assert_called_with(37) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + # does nothing + + assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_ECO}, + blocking=True, + ) + spa.set_heat_mode.assert_called_with(smarttub.Spa.HeatMode.ECONOMY) + + spa.get_status.return_value.heat_mode = smarttub.Spa.HeatMode.ECONOMY + await trigger_update(hass) + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_ECO + + spa.get_status.side_effect = smarttub.APIError + await trigger_update(hass) + # should not fail diff --git a/tests/components/smarttub/test_config_flow.py b/tests/components/smarttub/test_config_flow.py new file mode 100644 index 00000000000..2608d867c0d --- /dev/null +++ b/tests/components/smarttub/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test the smarttub config flow.""" +from unittest.mock import patch + +from smarttub import LoginFailed + +from homeassistant import config_entries +from homeassistant.components.smarttub.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.smarttub.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.smarttub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email" + assert result2["data"] == { + "email": "test-email", + "password": "test-password", + } + await hass.async_block_till_done() + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + 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"], {"email": "test-email2", "password": "test-password2"} + ) + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_form_invalid_auth(hass, smarttub_api): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + smarttub_api.login.side_effect = LoginFailed + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py new file mode 100644 index 00000000000..01989818d3b --- /dev/null +++ b/tests/components/smarttub/test_init.py @@ -0,0 +1,54 @@ +"""Test smarttub setup process.""" + +import asyncio + +from smarttub import LoginFailed + +from homeassistant.components import smarttub +from homeassistant.config_entries import ( + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.setup import async_setup_component + + +async def test_setup_with_no_config(setup_component, hass, smarttub_api): + """Test that we do not discover anything.""" + + # No flows started + assert len(hass.config_entries.flow.async_progress()) == 0 + + smarttub_api.login.assert_not_called() + + +async def test_setup_entry_not_ready(setup_component, hass, config_entry, smarttub_api): + """Test setup when the entry is not ready.""" + smarttub_api.login.side_effect = asyncio.TimeoutError + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_setup_auth_failed(setup_component, hass, config_entry, smarttub_api): + """Test setup when the credentials are invalid.""" + smarttub_api.login.side_effect = LoginFailed + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_config_passed_to_config_entry(hass, config_entry, config_data): + """Test that configured options are loaded via config entry.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, smarttub.DOMAIN, config_data) + + +async def test_unload_entry(hass, config_entry): + """Test being able to unload an entry.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + + assert await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/smarttub/test_light.py b/tests/components/smarttub/test_light.py new file mode 100644 index 00000000000..5e9d9459eab --- /dev/null +++ b/tests/components/smarttub/test_light.py @@ -0,0 +1,38 @@ +"""Test the SmartTub light platform.""" + +from smarttub import SpaLight + + +async def test_light(spa, setup_entry, hass): + """Test light entity.""" + + for light in spa.get_lights.return_value: + entity_id = f"light.{spa.brand}_{spa.model}_light_{light.zone}" + state = hass.states.get(entity_id) + assert state is not None + if light.mode == SpaLight.LightMode.OFF: + assert state.state == "off" + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + light.set_mode.assert_called() + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, "brightness": 255}, + blocking=True, + ) + light.set_mode.assert_called_with(SpaLight.LightMode.PURPLE, 100) + + else: + assert state.state == "on" + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) diff --git a/tests/components/smarttub/test_sensor.py b/tests/components/smarttub/test_sensor.py new file mode 100644 index 00000000000..5b0163daf26 --- /dev/null +++ b/tests/components/smarttub/test_sensor.py @@ -0,0 +1,59 @@ +"""Test the SmartTub sensor platform.""" + +from . import trigger_update + + +async def test_sensors(spa, setup_entry, hass): + """Test the sensors.""" + + entity_id = f"sensor.{spa.brand}_{spa.model}_state" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "normal" + + spa.get_status.return_value.state = "BAD" + await trigger_update(hass) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "bad" + + entity_id = f"sensor.{spa.brand}_{spa.model}_flow_switch" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "open" + + entity_id = f"sensor.{spa.brand}_{spa.model}_ozone" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + entity_id = f"sensor.{spa.brand}_{spa.model}_uv" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + entity_id = f"sensor.{spa.brand}_{spa.model}_blowout_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "inactive" + + entity_id = f"sensor.{spa.brand}_{spa.model}_cleanup_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "inactive" + + entity_id = f"sensor.{spa.brand}_{spa.model}_primary_filtration_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "inactive" + assert state.attributes["duration"] == 4 + assert state.attributes["last_updated"] is not None + assert state.attributes["mode"] == "normal" + assert state.attributes["start_hour"] == 2 + + entity_id = f"sensor.{spa.brand}_{spa.model}_secondary_filtration_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "inactive" + assert state.attributes["last_updated"] is not None + assert state.attributes["mode"] == "away" diff --git a/tests/components/smarttub/test_switch.py b/tests/components/smarttub/test_switch.py new file mode 100644 index 00000000000..8750bf79747 --- /dev/null +++ b/tests/components/smarttub/test_switch.py @@ -0,0 +1,38 @@ +"""Test the SmartTub switch platform.""" + +from smarttub import SpaPump + + +async def test_pumps(spa, setup_entry, hass): + """Test pump entities.""" + + for pump in spa.get_pumps.return_value: + if pump.type == SpaPump.PumpType.CIRCULATION: + entity_id = f"switch.{spa.brand}_{spa.model}_circulation_pump" + elif pump.type == SpaPump.PumpType.JET: + entity_id = f"switch.{spa.brand}_{spa.model}_jet_{pump.id.lower()}" + else: + raise NotImplementedError("Unknown pump type") + + state = hass.states.get(entity_id) + assert state is not None + if pump.state == SpaPump.PumpState.OFF: + assert state.state == "off" + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + pump.toggle.assert_called() + else: + assert state.state == "on" + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + pump.toggle.assert_called() diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 6a401ee0c16..ba9ba1c6db6 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -56,4 +56,5 @@ async def test_device_registry(hass, config_entry, config, soco): assert reg_device.sw_version == "49.2-64250" assert reg_device.connections == {("mac", "00:11:22:33:44:55")} assert reg_device.manufacturer == "Sonos" + assert reg_device.suggested_area == "Zone A" assert reg_device.name == "Zone A" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index bba809aedbb..8ca82e93bfc 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -14,15 +14,18 @@ async def test_scan_match_st(hass, caplog): """Test matching based on ST.""" scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]}) - with patch( - "netdisco.ssdp.scan", - return_value=[ + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( Mock( st="mock-st", location=None, values={"usn": "mock-usn", "server": "mock-server", "ext": ""}, ) - ], + ) + + with patch( + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -58,9 +61,14 @@ async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): ) scanner = ssdp.Scanner(hass, {"mock-domain": [{key: "Paulus"}]}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -95,9 +103,14 @@ async def test_scan_not_all_present(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -131,9 +144,14 @@ async def test_scan_not_all_match(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -148,9 +166,14 @@ async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): aioclient_mock.get("http://1.1.1.1", exc=exc) scanner = ssdp.Scanner(hass, {}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ): await scanner.async_scan(None) @@ -165,9 +188,14 @@ async def test_scan_description_parse_fail(hass, aioclient_mock): ) scanner = ssdp.Scanner(hass, {}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ): await scanner.async_scan(None) @@ -196,9 +224,14 @@ async def test_invalid_characters(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index c99cdef7984..5ec4f4217ce 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -5,9 +5,6 @@ import io import av import numpy as np -from homeassistant.components.stream import Stream -from homeassistant.components.stream.const import ATTR_STREAMS, DOMAIN - AUDIO_SAMPLE_RATE = 8000 @@ -93,10 +90,3 @@ def generate_h264_video(container_format="mp4", audio_codec=None): output.seek(0) return output - - -def preload_stream(hass, stream_source): - """Preload a stream for use in tests.""" - stream = Stream(hass, stream_source) - hass.data[DOMAIN][ATTR_STREAMS][stream_source] = stream - return stream diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 1b2f0645f9b..1b017667ee6 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -9,14 +9,13 @@ nothing for the test to verify. The solution is the WorkerSync class that allows the tests to pause the worker thread before finalizing the stream so that it can inspect the output. """ - import logging import threading from unittest.mock import patch import pytest -from homeassistant.components.stream.core import Segment, StreamOutput +from homeassistant.components.stream import Stream class WorkerSync: @@ -25,7 +24,7 @@ class WorkerSync: def __init__(self): """Initialize WorkerSync.""" self._event = None - self._put_original = StreamOutput.put + self._original = Stream._worker_finished def pause(self): """Pause the worker before it finalizes the stream.""" @@ -35,17 +34,17 @@ class WorkerSync: """Allow the worker thread to finalize the stream.""" self._event.set() - def blocking_put(self, stream_output: StreamOutput, segment: Segment): - """Proxy StreamOutput.put, intercepted for test to pause worker.""" - if segment is None and self._event: - # Worker is ending the stream, which clears all output buffers. - # Block the worker thread until the test has a chance to verify - # the segments under test. - logging.error("blocking worker") + def blocking_finish(self, stream: Stream): + """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 + # the segments under test. + logging.debug("blocking worker") + if self._event: self._event.wait() - # Forward to actual StreamOutput.put - self._put_original(stream_output, segment) + # Forward to actual implementation + self._original(stream) @pytest.fixture() @@ -53,8 +52,8 @@ def stream_worker_sync(hass): """Patch StreamOutput to allow test to synchronize worker stream end.""" sync = WorkerSync() with patch( - "homeassistant.components.stream.core.StreamOutput.put", - side_effect=sync.blocking_put, + "homeassistant.components.stream.Stream._worker_finished", + side_effect=sync.blocking_finish, autospec=True, ): yield sync diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 790222b1630..c11576d2570 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -1,20 +1,81 @@ """The tests for hls streams.""" from datetime import timedelta +import io from unittest.mock import patch from urllib.parse import urlparse import av +import pytest -from homeassistant.components.stream import request_stream +from homeassistant.components.stream import create_stream +from homeassistant.components.stream.const import MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS +from homeassistant.components.stream.core import Segment from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video, preload_stream +from tests.components.stream.common import generate_h264_video + +STREAM_SOURCE = "some-stream-source" +SEQUENCE_BYTES = io.BytesIO(b"some-bytes") +DURATION = 10 -async def test_hls_stream(hass, hass_client, stream_worker_sync): +class HlsClient: + """Test fixture for fetching the hls stream.""" + + def __init__(self, http_client, parsed_url): + """Initialize HlsClient.""" + self.http_client = http_client + self.parsed_url = parsed_url + + async def get(self, path=None): + """Fetch the hls stream for the specified path.""" + url = self.parsed_url.path + if path: + # Strip off the master playlist suffix and replace with path + url = "/".join(self.parsed_url.path.split("/")[:-1]) + path + return await self.http_client.get(url) + + +@pytest.fixture +def hls_stream(hass, hass_client): + """Create test fixture for creating an HLS client for a stream.""" + + async def create_client_for_stream(stream): + http_client = await hass_client() + parsed_url = urlparse(stream.endpoint_url("hls")) + return HlsClient(http_client, parsed_url) + + return create_client_for_stream + + +def make_segment(segment, discontinuity=False): + """Create a playlist response for a segment.""" + response = [] + if discontinuity: + response.append("#EXT-X-DISCONTINUITY") + response.extend(["#EXTINF:10.0000,", f"./segment/{segment}.m4s"]), + return "\n".join(response) + + +def make_playlist(sequence, discontinuity_sequence=0, segments=[]): + """Create a an hls playlist response for tests to assert on.""" + response = [ + "#EXTM3U", + "#EXT-X-VERSION:7", + "#EXT-X-TARGETDURATION:10", + '#EXT-X-MAP:URI="init.mp4"', + f"#EXT-X-MEDIA-SEQUENCE:{sequence}", + f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}", + ] + response.extend(segments) + response.append("") + return "\n".join(response) + + +async def test_hls_stream(hass, hls_stream, stream_worker_sync): """ Test hls stream. @@ -27,31 +88,27 @@ async def test_hls_stream(hass, hass_client, stream_worker_sync): # Setup demo HLS track source = generate_h264_video() - stream = preload_stream(hass, source) - stream.add_provider("hls") + stream = create_stream(hass, source) # Request stream - url = request_stream(hass, source) + stream.add_provider("hls") + stream.start() - http_client = await hass_client() + hls_client = await hls_stream(stream) # Fetch playlist - parsed_url = urlparse(url) - playlist_response = await http_client.get(parsed_url.path) + playlist_response = await hls_client.get() assert playlist_response.status == 200 # Fetch init playlist = await playlist_response.text() - playlist_url = "/".join(parsed_url.path.split("/")[:-1]) - init_url = playlist_url + "/init.mp4" - init_response = await http_client.get(init_url) + init_response = await hls_client.get("/init.mp4") assert init_response.status == 200 # Fetch segment playlist = await playlist_response.text() - playlist_url = "/".join(parsed_url.path.split("/")[:-1]) - segment_url = playlist_url + "/" + playlist.splitlines()[-1] - segment_response = await http_client.get(segment_url) + segment_url = "/" + playlist.splitlines()[-1] + segment_response = await hls_client.get(segment_url) assert segment_response.status == 200 stream_worker_sync.resume() @@ -60,7 +117,7 @@ async def test_hls_stream(hass, hass_client, stream_worker_sync): stream.stop() # Ensure playlist not accessible after stream ends - fail_response = await http_client.get(parsed_url.path) + fail_response = await hls_client.get() assert fail_response.status == HTTP_NOT_FOUND @@ -72,11 +129,12 @@ async def test_stream_timeout(hass, hass_client, stream_worker_sync): # Setup demo HLS track source = generate_h264_video() - stream = preload_stream(hass, source) - stream.add_provider("hls") + stream = create_stream(hass, source) # Request stream - url = request_stream(hass, source) + stream.add_provider("hls") + stream.start() + url = stream.endpoint_url("hls") http_client = await hass_client() @@ -98,12 +156,37 @@ async def test_stream_timeout(hass, hass_client, stream_worker_sync): # Wait 5 minutes future = dt_util.utcnow() + timedelta(minutes=5) async_fire_time_changed(hass, future) + await hass.async_block_till_done() # Ensure playlist not accessible fail_response = await http_client.get(parsed_url.path) assert fail_response.status == HTTP_NOT_FOUND +async def test_stream_timeout_after_stop(hass, hass_client, stream_worker_sync): + """Test hls stream timeout after the stream has been stopped already.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream_worker_sync.pause() + + # Setup demo HLS track + source = generate_h264_video() + stream = create_stream(hass, source) + + # Request stream + stream.add_provider("hls") + stream.start() + + stream_worker_sync.resume() + stream.stop() + + # Wait 5 minutes and fire callback. Stream should already have been + # stopped so this is a no-op. + future = dt_util.utcnow() + timedelta(minutes=5) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + async def test_stream_ended(hass, stream_worker_sync): """Test hls stream packets ended.""" await async_setup_component(hass, "stream", {"stream": {}}) @@ -112,11 +195,13 @@ async def test_stream_ended(hass, stream_worker_sync): # Setup demo HLS track source = generate_h264_video() - stream = preload_stream(hass, source) + stream = create_stream(hass, source) track = stream.add_provider("hls") # Request stream - request_stream(hass, source) + stream.add_provider("hls") + stream.start() + stream.endpoint_url("hls") # Run it dead while True: @@ -141,9 +226,10 @@ async def test_stream_keepalive(hass): # Setup demo HLS track source = "test_stream_keepalive_source" - stream = preload_stream(hass, source) + stream = create_stream(hass, source) track = stream.add_provider("hls") track.num_segments = 2 + stream.start() cur_time = 0 @@ -155,17 +241,173 @@ async def test_stream_keepalive(hass): return cur_time with patch("av.open") as av_open, patch( - "homeassistant.components.stream.worker.time" + "homeassistant.components.stream.time" ) as mock_time, patch( - "homeassistant.components.stream.worker.STREAM_RESTART_INCREMENT", 0 + "homeassistant.components.stream.STREAM_RESTART_INCREMENT", 0 ): av_open.side_effect = av.error.InvalidDataError(-2, "error") mock_time.time.side_effect = time_side_effect # Request stream - request_stream(hass, source, keepalive=True) + stream.keepalive = True + stream.start() stream._thread.join() stream._thread = None assert av_open.call_count == 2 # Stop stream, if it hasn't quit already stream.stop() + + +async def test_hls_playlist_view_no_output(hass, hass_client, hls_stream): + """Test rendering the hls playlist with no output segments.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream = create_stream(hass, STREAM_SOURCE) + stream.add_provider("hls") + + hls_client = await hls_stream(stream) + + # Fetch playlist + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 404 + + +async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): + """Test rendering the hls playlist with 1 and 2 output segments.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream = create_stream(hass, STREAM_SOURCE) + stream_worker_sync.pause() + hls = stream.add_provider("hls") + + hls.put(Segment(1, SEQUENCE_BYTES, DURATION)) + await hass.async_block_till_done() + + hls_client = await hls_stream(stream) + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist(sequence=1, segments=[make_segment(1)]) + + hls.put(Segment(2, SEQUENCE_BYTES, DURATION)) + await hass.async_block_till_done() + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=1, segments=[make_segment(1), make_segment(2)] + ) + + stream_worker_sync.resume() + stream.stop() + + +async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): + """Test rendering the hls playlist with more segments than the segment deque can hold.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream = create_stream(hass, STREAM_SOURCE) + stream_worker_sync.pause() + hls = stream.add_provider("hls") + + hls_client = await hls_stream(stream) + + # Produce enough segments to overfill the output buffer by one + for sequence in range(1, MAX_SEGMENTS + 2): + hls.put(Segment(sequence, SEQUENCE_BYTES, DURATION)) + await hass.async_block_till_done() + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + + # Only NUM_PLAYLIST_SEGMENTS are returned in the playlist. + start = MAX_SEGMENTS + 2 - NUM_PLAYLIST_SEGMENTS + segments = [] + for sequence in range(start, MAX_SEGMENTS + 2): + segments.append(make_segment(sequence)) + assert await resp.text() == make_playlist( + sequence=start, + segments=segments, + ) + + # Fetch the actual segments with a fake byte payload + with patch( + "homeassistant.components.stream.hls.get_m4s", return_value=b"fake-payload" + ): + # The segment that fell off the buffer is not accessible + segment_response = await hls_client.get("/segment/1.m4s") + assert segment_response.status == 404 + + # However all segments in the buffer are accessible, even those that were not in the playlist. + for sequence in range(2, MAX_SEGMENTS + 2): + segment_response = await hls_client.get(f"/segment/{sequence}.m4s") + assert segment_response.status == 200 + + stream_worker_sync.resume() + stream.stop() + + +async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_sync): + """Test a discontinuity across segments in the stream with 3 segments.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream = create_stream(hass, STREAM_SOURCE) + stream_worker_sync.pause() + hls = stream.add_provider("hls") + + hls.put(Segment(1, SEQUENCE_BYTES, DURATION, stream_id=0)) + hls.put(Segment(2, SEQUENCE_BYTES, DURATION, stream_id=0)) + hls.put(Segment(3, SEQUENCE_BYTES, DURATION, stream_id=1)) + await hass.async_block_till_done() + + hls_client = await hls_stream(stream) + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=1, + segments=[ + make_segment(1), + make_segment(2), + make_segment(3, discontinuity=True), + ], + ) + + stream_worker_sync.resume() + stream.stop() + + +async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sync): + """Test a discontinuity with more segments than the segment deque can hold.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + stream = create_stream(hass, STREAM_SOURCE) + stream_worker_sync.pause() + hls = stream.add_provider("hls") + + hls_client = await hls_stream(stream) + + hls.put(Segment(1, SEQUENCE_BYTES, DURATION, stream_id=0)) + + # Produce enough segments to overfill the output buffer by one + for sequence in range(1, MAX_SEGMENTS + 2): + hls.put(Segment(sequence, SEQUENCE_BYTES, DURATION, stream_id=1)) + await hass.async_block_till_done() + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + + # Only NUM_PLAYLIST_SEGMENTS are returned in the playlist causing the + # EXT-X-DISCONTINUITY tag to be omitted and EXT-X-DISCONTINUITY-SEQUENCE + # returned instead. + start = MAX_SEGMENTS + 2 - NUM_PLAYLIST_SEGMENTS + segments = [] + for sequence in range(start, MAX_SEGMENTS + 2): + segments.append(make_segment(sequence)) + assert await resp.text() == make_playlist( + sequence=start, + discontinuity_sequence=1, + segments=segments, + ) + + stream_worker_sync.resume() + stream.stop() diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py deleted file mode 100644 index 1515ff1a490..00000000000 --- a/tests/components/stream/test_init.py +++ /dev/null @@ -1,84 +0,0 @@ -"""The tests for stream.""" -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from homeassistant.components.stream.const import ( - ATTR_STREAMS, - CONF_LOOKBACK, - CONF_STREAM_SOURCE, - DOMAIN, - SERVICE_RECORD, -) -from homeassistant.const import CONF_FILENAME -from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component - - -async def test_record_service_invalid_file(hass): - """Test record service call with invalid file.""" - await async_setup_component(hass, "stream", {"stream": {}}) - data = {CONF_STREAM_SOURCE: "rtsp://my.video", CONF_FILENAME: "/my/invalid/path"} - with pytest.raises(HomeAssistantError): - await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True) - - -async def test_record_service_init_stream(hass): - """Test record service call with invalid file.""" - await async_setup_component(hass, "stream", {"stream": {}}) - data = {CONF_STREAM_SOURCE: "rtsp://my.video", CONF_FILENAME: "/my/invalid/path"} - with patch("homeassistant.components.stream.Stream") as stream_mock, patch.object( - hass.config, "is_allowed_path", return_value=True - ): - # Setup stubs - stream_mock.return_value.outputs = {} - - # Call Service - await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True) - - # Assert - assert stream_mock.called - - -async def test_record_service_existing_record_session(hass): - """Test record service call with invalid file.""" - await async_setup_component(hass, "stream", {"stream": {}}) - source = "rtsp://my.video" - data = {CONF_STREAM_SOURCE: source, CONF_FILENAME: "/my/invalid/path"} - - # Setup stubs - stream_mock = MagicMock() - stream_mock.return_value.outputs = {"recorder": MagicMock()} - hass.data[DOMAIN][ATTR_STREAMS][source] = stream_mock - - with patch.object(hass.config, "is_allowed_path", return_value=True), pytest.raises( - HomeAssistantError - ): - # Call Service - await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True) - - -async def test_record_service_lookback(hass): - """Test record service call with invalid file.""" - await async_setup_component(hass, "stream", {"stream": {}}) - data = { - CONF_STREAM_SOURCE: "rtsp://my.video", - CONF_FILENAME: "/my/invalid/path", - CONF_LOOKBACK: 4, - } - - with patch("homeassistant.components.stream.Stream") as stream_mock, patch.object( - hass.config, "is_allowed_path", return_value=True - ): - # Setup stubs - hls_mock = MagicMock() - hls_mock.target_duration = 2 - hls_mock.recv = AsyncMock(return_value=None) - stream_mock.return_value.outputs = {"hls": hls_mock} - - # Call Service - await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True) - - assert stream_mock.called - stream_mock.return_value.add_provider.assert_called_once_with("recorder") - assert hls_mock.recv.called diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 1b46738c8f2..199020097bd 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -8,13 +8,15 @@ from unittest.mock import patch import av import pytest +from homeassistant.components.stream import create_stream from homeassistant.components.stream.core import Segment from homeassistant.components.stream.recorder import recorder_save_worker +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video, preload_stream +from tests.components.stream.common import generate_h264_video TEST_TIMEOUT = 10 @@ -75,10 +77,11 @@ async def test_record_stream(hass, hass_client, stream_worker_sync, record_worke # Setup demo track source = generate_h264_video() - stream = preload_stream(hass, source) - recorder = stream.add_provider("recorder") - stream.start() + stream = create_stream(hass, source) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path") + recorder = stream.add_provider("recorder") while True: segment = await recorder.recv() if not segment: @@ -95,6 +98,27 @@ async def test_record_stream(hass, hass_client, stream_worker_sync, record_worke record_worker_sync.join() +async def test_record_lookback( + hass, hass_client, stream_worker_sync, record_worker_sync +): + """Exercise record with loopback.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + source = generate_h264_video() + stream = create_stream(hass, source) + + # Start an HLS feed to enable lookback + stream.add_provider("hls") + stream.start() + + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path", lookback=4) + + # This test does not need recorder cleanup since it is not fully exercised + + stream.stop() + + async def test_recorder_timeout(hass, hass_client, stream_worker_sync): """ Test recorder timeout. @@ -106,14 +130,14 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync): stream_worker_sync.pause() - with patch( - "homeassistant.components.stream.recorder.RecorderOutput.cleanup" - ) as mock_cleanup: + with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout: # Setup demo track source = generate_h264_video() - stream = preload_stream(hass, source) + + stream = create_stream(hass, source) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path") recorder = stream.add_provider("recorder") - stream.start() await recorder.recv() @@ -122,7 +146,7 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync): async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert mock_cleanup.called + assert mock_timeout.called stream_worker_sync.resume() stream.stop() @@ -130,6 +154,19 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync): await hass.async_block_till_done() +async def test_record_path_not_allowed(hass, hass_client): + """Test where the output path is not allowed by home assistant configuration.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + # Setup demo track + source = generate_h264_video() + stream = create_stream(hass, source) + with patch.object( + hass.config, "is_allowed_path", return_value=False + ), pytest.raises(HomeAssistantError): + await stream.async_record("/example/path") + + async def test_recorder_save(tmpdir): """Test recorder save.""" # Setup @@ -137,7 +174,20 @@ async def test_recorder_save(tmpdir): filename = f"{tmpdir}/test.mp4" # Run - recorder_save_worker(filename, [Segment(1, source, 4)], "mp4") + recorder_save_worker(filename, [Segment(1, source, 4)]) + + # Assert + assert os.path.exists(filename) + + +async def test_recorder_discontinuity(tmpdir): + """Test recorder save across a discontinuity.""" + # Setup + source = generate_h264_video() + filename = f"{tmpdir}/test.mp4" + + # Run + recorder_save_worker(filename, [Segment(1, source, 4, 0), Segment(2, source, 4, 1)]) # Assert assert os.path.exists(filename) @@ -167,9 +217,10 @@ async def test_record_stream_audio( source = generate_h264_video( container_format="mov", audio_codec=a_codec ) # mov can store PCM - stream = preload_stream(hass, source) + stream = create_stream(hass, source) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path") recorder = stream.add_provider("recorder") - stream.start() while True: segment = await recorder.recv() diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py new file mode 100644 index 00000000000..2c202a290ce --- /dev/null +++ b/tests/components/stream/test_worker.py @@ -0,0 +1,549 @@ +"""Test the stream worker corner cases. + +Exercise the stream worker functionality by mocking av.open calls to return a +fake media container as well a fake decoded stream in the form of a series of +packets. This is needed as some of these cases can't be encoded using pyav. It +is preferred to use test_hls.py for example, when possible. + +The worker opens the stream source (typically a URL) and gets back a +container that has audio/video streams. The worker iterates over the sequence +of packets and sends them to the appropriate output buffers. Each test +creates a packet sequence, with a mocked output buffer to capture the segments +pushed to the output streams. The packet sequence can be used to exercise +failure modes or corner cases like how out of order packets are handled. +""" + +import fractions +import io +import math +import threading +from unittest.mock import patch + +import av + +from homeassistant.components.stream import Stream +from homeassistant.components.stream.const import ( + MAX_MISSING_DTS, + MIN_SEGMENT_DURATION, + PACKETS_TO_WAIT_FOR_AUDIO, +) +from homeassistant.components.stream.worker import SegmentBuffer, stream_worker + +STREAM_SOURCE = "some-stream-source" +# Formats here are arbitrary, not exercised by tests +STREAM_OUTPUT_FORMAT = "hls" +AUDIO_STREAM_FORMAT = "mp3" +VIDEO_STREAM_FORMAT = "h264" +VIDEO_FRAME_RATE = 12 +AUDIO_SAMPLE_RATE = 11025 +PACKET_DURATION = fractions.Fraction(1, VIDEO_FRAME_RATE) # in seconds +SEGMENT_DURATION = ( + math.ceil(MIN_SEGMENT_DURATION / PACKET_DURATION) * PACKET_DURATION +) # in seconds +TEST_SEQUENCE_LENGTH = 5 * VIDEO_FRAME_RATE +LONGER_TEST_SEQUENCE_LENGTH = 20 * VIDEO_FRAME_RATE +OUT_OF_ORDER_PACKET_INDEX = 3 * VIDEO_FRAME_RATE +PACKETS_PER_SEGMENT = SEGMENT_DURATION / PACKET_DURATION +SEGMENTS_PER_PACKET = PACKET_DURATION / SEGMENT_DURATION +TIMEOUT = 15 + + +class FakePyAvStream: + """A fake pyav Stream.""" + + def __init__(self, name, rate): + """Initialize the stream.""" + self.name = name + self.time_base = fractions.Fraction(1, rate) + self.profile = "ignored-profile" + + +VIDEO_STREAM = FakePyAvStream(VIDEO_STREAM_FORMAT, VIDEO_FRAME_RATE) +AUDIO_STREAM = FakePyAvStream(AUDIO_STREAM_FORMAT, AUDIO_SAMPLE_RATE) + + +class PacketSequence: + """Creates packets in a sequence for exercising stream worker behavior. + + A test can create a PacketSequence(N) that will raise a StopIteration after + N packets. Each packet has an arbitrary monotomically increasing dts/pts value + that is parseable by the worker, but a test can manipulate the values to + exercise corner cases. + """ + + def __init__(self, num_packets): + """Initialize the sequence with the number of packets it provides.""" + self.packet = 0 + self.num_packets = num_packets + + def __iter__(self): + """Reset the sequence.""" + self.packet = 0 + return self + + def __next__(self): + """Return the next packet.""" + if self.packet >= self.num_packets: + raise StopIteration + self.packet += 1 + + class FakePacket: + time_base = fractions.Fraction(1, VIDEO_FRAME_RATE) + dts = self.packet * PACKET_DURATION / time_base + pts = self.packet * PACKET_DURATION / time_base + duration = PACKET_DURATION / time_base + stream = VIDEO_STREAM + is_keyframe = True + + return FakePacket() + + +class FakePyAvContainer: + """A fake container returned by mock av.open for a stream.""" + + def __init__(self, video_stream, audio_stream): + """Initialize the fake container.""" + # Tests can override this to trigger different worker behavior + self.packets = PacketSequence(0) + + class FakePyAvStreams: + video = video_stream + audio = audio_stream + + self.streams = FakePyAvStreams() + + class FakePyAvFormat: + name = "ignored-format" + + self.format = FakePyAvFormat() + + def demux(self, streams): + """Decode the streams from container, and return a packet sequence.""" + return self.packets + + def close(self): + """Close the container.""" + return + + +class FakePyAvBuffer: + """Holds outputs of the decoded stream for tests to assert on results.""" + + def __init__(self): + """Initialize the FakePyAvBuffer.""" + self.segments = [] + self.audio_packets = [] + self.video_packets = [] + + def add_stream(self, template=None): + """Create an output buffer that captures packets for test to examine.""" + + class FakeStream: + def __init__(self, capture_packets): + self.capture_packets = capture_packets + + def close(self): + return + + def mux(self, packet): + self.capture_packets.append(packet) + + if template.name == AUDIO_STREAM_FORMAT: + return FakeStream(self.audio_packets) + return FakeStream(self.video_packets) + + def mux(self, packet): + """Capture a packet for tests to examine.""" + # Forward to appropriate FakeStream + packet.stream.mux(packet) + + def close(self): + """Close the buffer.""" + return + + def capture_output_segment(self, segment): + """Capture the output segment for tests to inspect.""" + self.segments.append(segment) + + +class MockPyAv: + """Mocks out av.open.""" + + def __init__(self, video=True, audio=False): + """Initialize the MockPyAv.""" + video_stream = [VIDEO_STREAM] if video else [] + audio_stream = [AUDIO_STREAM] if audio else [] + self.container = FakePyAvContainer( + video_stream=video_stream, audio_stream=audio_stream + ) + self.capture_buffer = FakePyAvBuffer() + + def open(self, stream_source, *args, **kwargs): + """Return a stream or buffer depending on args.""" + if isinstance(stream_source, io.BytesIO): + return self.capture_buffer + return self.container + + +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) + stream.add_provider(STREAM_OUTPUT_FORMAT) + + if not py_av: + py_av = MockPyAv() + py_av.container.packets = packets + + with patch("av.open", new=py_av.open), patch( + "homeassistant.components.stream.core.StreamOutput.put", + side_effect=py_av.capture_buffer.capture_output_segment, + ): + segment_buffer = SegmentBuffer(stream.outputs) + stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) + await hass.async_block_till_done() + + return py_av.capture_buffer + + +async def test_stream_open_fails(hass): + """Test failure on stream open.""" + stream = Stream(hass, STREAM_SOURCE) + stream.add_provider(STREAM_OUTPUT_FORMAT) + with patch("av.open") as av_open: + av_open.side_effect = av.error.InvalidDataError(-2, "error") + segment_buffer = SegmentBuffer(stream.outputs) + stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) + await hass.async_block_till_done() + av_open.assert_called_once() + + +async def test_stream_worker_success(hass): + """Test a short stream that ends and outputs everything correctly.""" + decoded_stream = await async_decode_stream( + hass, PacketSequence(TEST_SEQUENCE_LENGTH) + ) + segments = decoded_stream.segments + # Check number of segments. A segment is only formed when a packet from the next + # segment arrives, hence the subtraction of one from the sequence length. + assert len(segments) == int((TEST_SEQUENCE_LENGTH - 1) * SEGMENTS_PER_PACKET) + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == TEST_SEQUENCE_LENGTH + assert len(decoded_stream.audio_packets) == 0 + + +async def test_skip_out_of_order_packet(hass): + """Skip a single out of order packet.""" + packets = list(PacketSequence(TEST_SEQUENCE_LENGTH)) + # This packet is out of order + packets[OUT_OF_ORDER_PACKET_INDEX].dts = -9090 + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # If skipped packet would have been the first packet of a segment, the previous + # segment will be longer by a packet duration + # We also may possibly lose a segment due to the shifting pts boundary + if OUT_OF_ORDER_PACKET_INDEX % PACKETS_PER_SEGMENT == 0: + # Check duration of affected segment and remove it + longer_segment_index = int( + (OUT_OF_ORDER_PACKET_INDEX - 1) * SEGMENTS_PER_PACKET + ) + assert ( + segments[longer_segment_index].duration + == SEGMENT_DURATION + PACKET_DURATION + ) + del segments[longer_segment_index] + # Check number of segments + assert len(segments) == int((len(packets) - 1 - 1) * SEGMENTS_PER_PACKET - 1) + else: # Otherwise segment durations and number of segments are unaffected + # Check number of segments + assert len(segments) == int((len(packets) - 1) * SEGMENTS_PER_PACKET) + # Check remaining segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == len(packets) - 1 + assert len(decoded_stream.audio_packets) == 0 + + +async def test_discard_old_packets(hass): + """Skip a series of out of order packets.""" + + packets = list(PacketSequence(TEST_SEQUENCE_LENGTH)) + # Packets after this one are considered out of order + packets[OUT_OF_ORDER_PACKET_INDEX - 1].dts = 9090 + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + # Check number of segments + assert len(segments) == int((OUT_OF_ORDER_PACKET_INDEX - 1) * SEGMENTS_PER_PACKET) + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == OUT_OF_ORDER_PACKET_INDEX + assert len(decoded_stream.audio_packets) == 0 + + +async def test_packet_overflow(hass): + """Packet is too far out of order, and looks like overflow, ending stream early.""" + + packets = list(PacketSequence(TEST_SEQUENCE_LENGTH)) + # 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, iter(packets)) + segments = decoded_stream.segments + # Check number of segments + assert len(segments) == int((OUT_OF_ORDER_PACKET_INDEX - 1) * SEGMENTS_PER_PACKET) + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == OUT_OF_ORDER_PACKET_INDEX + assert len(decoded_stream.audio_packets) == 0 + + +async def test_skip_initial_bad_packets(hass): + """Tests a small number of initial "bad" packets with missing dts.""" + + num_packets = LONGER_TEST_SEQUENCE_LENGTH + packets = list(PacketSequence(num_packets)) + num_bad_packets = MAX_MISSING_DTS - 1 + for i in range(0, num_bad_packets): + packets[i].dts = None + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + # Check number of segments + assert len(segments) == int( + (num_packets - num_bad_packets - 1) * SEGMENTS_PER_PACKET + ) + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == num_packets - num_bad_packets + assert len(decoded_stream.audio_packets) == 0 + + +async def test_too_many_initial_bad_packets_fails(hass): + """Test initial bad packets are too high, causing it to never start.""" + + num_packets = LONGER_TEST_SEQUENCE_LENGTH + packets = list(PacketSequence(num_packets)) + num_bad_packets = MAX_MISSING_DTS + 1 + for i in range(0, num_bad_packets): + packets[i].dts = None + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + assert len(segments) == 0 + assert len(decoded_stream.video_packets) == 0 + assert len(decoded_stream.audio_packets) == 0 + + +async def test_skip_missing_dts(hass): + """Test packets in the middle of the stream missing DTS are skipped.""" + + num_packets = LONGER_TEST_SEQUENCE_LENGTH + packets = list(PacketSequence(num_packets)) + bad_packet_start = int(LONGER_TEST_SEQUENCE_LENGTH / 2) + num_bad_packets = MAX_MISSING_DTS - 1 + for i in range(bad_packet_start, bad_packet_start + num_bad_packets): + packets[i].dts = None + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations (not counting the elongated segment) + assert ( + sum([segments[i].duration == SEGMENT_DURATION for i in range(len(segments))]) + >= len(segments) - 1 + ) + assert len(decoded_stream.video_packets) == num_packets - num_bad_packets + assert len(decoded_stream.audio_packets) == 0 + + +async def test_too_many_bad_packets(hass): + """Test bad packets are too many, causing it to end.""" + + num_packets = LONGER_TEST_SEQUENCE_LENGTH + packets = list(PacketSequence(num_packets)) + bad_packet_start = int(LONGER_TEST_SEQUENCE_LENGTH / 2) + num_bad_packets = MAX_MISSING_DTS + 1 + for i in range(bad_packet_start, bad_packet_start + num_bad_packets): + packets[i].dts = None + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + assert len(segments) == int((bad_packet_start - 1) * SEGMENTS_PER_PACKET) + assert len(decoded_stream.video_packets) == bad_packet_start + assert len(decoded_stream.audio_packets) == 0 + + +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 + ) + # Note: This failure scenario does not output an end of stream + segments = decoded_stream.segments + assert len(segments) == 0 + assert len(decoded_stream.video_packets) == 0 + assert len(decoded_stream.audio_packets) == 0 + + +async def test_audio_packets_not_found(hass): + """Set up an audio stream, but no audio packets are found.""" + py_av = MockPyAv(audio=True) + + num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 + packets = PacketSequence(num_packets) # Contains only video packets + + decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + segments = decoded_stream.segments + assert len(segments) == int((num_packets - 1) * SEGMENTS_PER_PACKET) + assert len(decoded_stream.video_packets) == num_packets + assert len(decoded_stream.audio_packets) == 0 + + +async def test_audio_is_first_packet(hass): + """Set up an audio stream and audio packet is the first packet in the stream.""" + py_av = MockPyAv(audio=True) + + num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 + packets = list(PacketSequence(num_packets)) + # Pair up an audio packet for each video packet + packets[0].stream = AUDIO_STREAM + packets[0].dts = packets[1].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + packets[0].pts = packets[1].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + packets[2].stream = AUDIO_STREAM + packets[2].dts = packets[3].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + packets[2].pts = packets[3].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + + decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + segments = decoded_stream.segments + # The audio packets are segmented with the video packets + assert len(segments) == int((num_packets - 2 - 1) * SEGMENTS_PER_PACKET) + assert len(decoded_stream.video_packets) == num_packets - 2 + assert len(decoded_stream.audio_packets) == 1 + + +async def test_audio_packets_found(hass): + """Set up an audio stream and audio packets are found at the start of the stream.""" + py_av = MockPyAv(audio=True) + + num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 + packets = list(PacketSequence(num_packets)) + packets[1].stream = AUDIO_STREAM + packets[1].dts = packets[0].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + packets[1].pts = packets[0].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE + + decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + segments = decoded_stream.segments + # The audio packet above is buffered with the video packet + assert len(segments) == int((num_packets - 1 - 1) * SEGMENTS_PER_PACKET) + assert len(decoded_stream.video_packets) == num_packets - 1 + assert len(decoded_stream.audio_packets) == 1 + + +async def test_pts_out_of_order(hass): + """Test pts can be out of order and still be valid.""" + + # Create a sequence of packets with some out of order pts + packets = list(PacketSequence(TEST_SEQUENCE_LENGTH)) + for i, _ in enumerate(packets): + if i % PACKETS_PER_SEGMENT == 1: + packets[i].pts = packets[i - 1].pts - 1 + packets[i].is_keyframe = False + + decoded_stream = await async_decode_stream(hass, iter(packets)) + segments = decoded_stream.segments + # Check number of segments + assert len(segments) == int((TEST_SEQUENCE_LENGTH - 1) * SEGMENTS_PER_PACKET) + # Check sequence numbers + assert all([segments[i].sequence == i + 1 for i in range(len(segments))]) + # Check segment durations + assert all([s.duration == SEGMENT_DURATION for s in segments]) + assert len(decoded_stream.video_packets) == len(packets) + assert len(decoded_stream.audio_packets) == 0 + + +async def test_stream_stopped_while_decoding(hass): + """Tests that worker quits when stop() is called while decodign.""" + # Add some synchronization so that the test can pause the background + # worker. When the worker is stopped, the test invokes stop() which + # will cause the worker thread to exit once it enters the decode + # loop + worker_open = threading.Event() + worker_wake = threading.Event() + + stream = Stream(hass, STREAM_SOURCE) + stream.add_provider(STREAM_OUTPUT_FORMAT) + + py_av = MockPyAv() + py_av.container.packets = PacketSequence(TEST_SEQUENCE_LENGTH) + + def blocking_open(stream_source, *args, **kwargs): + # Let test know the thread is running + worker_open.set() + # Block worker thread until test wakes up + worker_wake.wait() + return py_av.open(stream_source, args, kwargs) + + with patch("av.open", new=blocking_open): + stream.start() + assert worker_open.wait(TIMEOUT) + # Note: There is a race here where the worker could start as soon + # as the wake event is sent, completing all decode work. + worker_wake.set() + stream.stop() + + +async def test_update_stream_source(hass): + """Tests that the worker is re-invoked when the stream source is updated.""" + worker_open = threading.Event() + worker_wake = threading.Event() + + stream = Stream(hass, STREAM_SOURCE) + stream.add_provider(STREAM_OUTPUT_FORMAT) + # Note that keepalive is not set here. The stream is "restarted" even though + # it is not stopping due to failure. + + py_av = MockPyAv() + py_av.container.packets = PacketSequence(TEST_SEQUENCE_LENGTH) + + last_stream_source = None + + def blocking_open(stream_source, *args, **kwargs): + nonlocal last_stream_source + if not isinstance(stream_source, io.BytesIO): + last_stream_source = stream_source + # Let test know the thread is running + worker_open.set() + # Block worker thread until test wakes up + worker_wake.wait() + return py_av.open(stream_source, args, kwargs) + + with patch("av.open", new=blocking_open): + stream.start() + assert worker_open.wait(TIMEOUT) + assert last_stream_source == STREAM_SOURCE + + # 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) + worker_open.clear() + stream.update_source(STREAM_SOURCE + "-updated-source") + worker_wake.set() + assert worker_open.wait(TIMEOUT) + assert last_stream_source == STREAM_SOURCE + "-updated-source" + worker_wake.set() + + # Ccleanup + stream.stop() diff --git a/tests/components/subaru/__init__.py b/tests/components/subaru/__init__.py new file mode 100644 index 00000000000..26b81c84a1e --- /dev/null +++ b/tests/components/subaru/__init__.py @@ -0,0 +1 @@ +"""Tests for the Subaru integration.""" diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py new file mode 100644 index 00000000000..b6a79ab8829 --- /dev/null +++ b/tests/components/subaru/api_responses.py @@ -0,0 +1,284 @@ +"""Sample API response data for tests.""" + +from homeassistant.components.subaru.const import ( + API_GEN_1, + API_GEN_2, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_NAME, + VEHICLE_VIN, +) + +TEST_VIN_1_G1 = "JF2ABCDE6L0000001" +TEST_VIN_2_EV = "JF2ABCDE6L0000002" +TEST_VIN_3_G2 = "JF2ABCDE6L0000003" + +VEHICLE_DATA = { + TEST_VIN_1_G1: { + VEHICLE_VIN: TEST_VIN_1_G1, + VEHICLE_NAME: "test_vehicle_1", + VEHICLE_HAS_EV: False, + VEHICLE_API_GEN: API_GEN_1, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: False, + }, + TEST_VIN_2_EV: { + VEHICLE_VIN: TEST_VIN_2_EV, + VEHICLE_NAME: "test_vehicle_2", + VEHICLE_HAS_EV: True, + VEHICLE_API_GEN: API_GEN_2, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: True, + }, + TEST_VIN_3_G2: { + VEHICLE_VIN: TEST_VIN_3_G2, + VEHICLE_NAME: "test_vehicle_3", + VEHICLE_HAS_EV: False, + VEHICLE_API_GEN: API_GEN_2, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: True, + }, +} + +VEHICLE_STATUS_EV = { + "status": { + "AVG_FUEL_CONSUMPTION": 2.3, + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": 707, + "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "DOOR_BOOT_POSITION": "CLOSED", + "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", + "DOOR_ENGINE_HOOD_POSITION": "CLOSED", + "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_LEFT_POSITION": "CLOSED", + "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_RIGHT_POSITION": "CLOSED", + "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_LEFT_POSITION": "CLOSED", + "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": 17, + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "65535", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": 1234, + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", + "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", + "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": 2550, + "TYRE_PRESSURE_FRONT_RIGHT": 2550, + "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_REAR_RIGHT": 2350, + "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", + "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", + "TYRE_STATUS_REAR_LEFT": "UNKNOWN", + "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "WINDOW_BACK_STATUS": "UNKNOWN", + "WINDOW_FRONT_LEFT_STATUS": "VENTED", + "WINDOW_FRONT_RIGHT_STATUS": "VENTED", + "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", + "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", + "WINDOW_SUNROOF_STATUS": "UNKNOWN", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, + } +} + +VEHICLE_STATUS_G2 = { + "status": { + "AVG_FUEL_CONSUMPTION": 2.3, + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": 707, + "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "DOOR_BOOT_POSITION": "CLOSED", + "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", + "DOOR_ENGINE_HOOD_POSITION": "CLOSED", + "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_LEFT_POSITION": "CLOSED", + "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_RIGHT_POSITION": "CLOSED", + "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_LEFT_POSITION": "CLOSED", + "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": 1234, + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", + "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", + "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": 2550, + "TYRE_PRESSURE_FRONT_RIGHT": 2550, + "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_REAR_RIGHT": 2350, + "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", + "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", + "TYRE_STATUS_REAR_LEFT": "UNKNOWN", + "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "WINDOW_BACK_STATUS": "UNKNOWN", + "WINDOW_FRONT_LEFT_STATUS": "VENTED", + "WINDOW_FRONT_RIGHT_STATUS": "VENTED", + "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", + "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", + "WINDOW_SUNROOF_STATUS": "UNKNOWN", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, + } +} + +EXPECTED_STATE_EV_IMPERIAL = { + "AVG_FUEL_CONSUMPTION": "102.3", + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": "439.3", + "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": "17", + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "unknown", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "70.7", + "ODOMETER": "766.8", + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": "37.0", + "TYRE_PRESSURE_FRONT_RIGHT": "37.0", + "TYRE_PRESSURE_REAR_LEFT": "35.5", + "TYRE_PRESSURE_REAR_RIGHT": "34.1", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, +} + +EXPECTED_STATE_EV_METRIC = { + "AVG_FUEL_CONSUMPTION": "2.3", + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": "707", + "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": "27.4", + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "unknown", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": "1234", + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": "2550", + "TYRE_PRESSURE_FRONT_RIGHT": "2550", + "TYRE_PRESSURE_REAR_LEFT": "2450", + "TYRE_PRESSURE_REAR_RIGHT": "2350", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, +} + +EXPECTED_STATE_EV_UNAVAILABLE = { + "AVG_FUEL_CONSUMPTION": "unavailable", + "BATTERY_VOLTAGE": "unavailable", + "DISTANCE_TO_EMPTY_FUEL": "unavailable", + "EV_CHARGER_STATE_TYPE": "unavailable", + "EV_CHARGE_SETTING_AMPERE_TYPE": "unavailable", + "EV_CHARGE_VOLT_TYPE": "unavailable", + "EV_DISTANCE_TO_EMPTY": "unavailable", + "EV_IS_PLUGGED_IN": "unavailable", + "EV_STATE_OF_CHARGE_MODE": "unavailable", + "EV_STATE_OF_CHARGE_PERCENT": "unavailable", + "EV_TIME_TO_FULLY_CHARGED": "unavailable", + "EV_VEHICLE_TIME_DAYOFWEEK": "unavailable", + "EV_VEHICLE_TIME_HOUR": "unavailable", + "EV_VEHICLE_TIME_MINUTE": "unavailable", + "EV_VEHICLE_TIME_SECOND": "unavailable", + "EXT_EXTERNAL_TEMP": "unavailable", + "ODOMETER": "unavailable", + "POSITION_HEADING_DEGREE": "unavailable", + "POSITION_SPEED_KMPH": "unavailable", + "POSITION_TIMESTAMP": "unavailable", + "TIMESTAMP": "unavailable", + "TRANSMISSION_MODE": "unavailable", + "TYRE_PRESSURE_FRONT_LEFT": "unavailable", + "TYRE_PRESSURE_FRONT_RIGHT": "unavailable", + "TYRE_PRESSURE_REAR_LEFT": "unavailable", + "TYRE_PRESSURE_REAR_RIGHT": "unavailable", + "VEHICLE_STATE_TYPE": "unavailable", + "heading": "unavailable", + "latitude": "unavailable", + "longitude": "unavailable", +} diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py new file mode 100644 index 00000000000..8216ca2d2c2 --- /dev/null +++ b/tests/components/subaru/conftest.py @@ -0,0 +1,139 @@ +"""Common functions needed to setup tests for Subaru component.""" +from unittest.mock import patch + +import pytest +from subarulink.const import COUNTRY_USA + +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +from homeassistant.components.subaru.const import ( + CONF_COUNTRY, + CONF_UPDATE_ENABLED, + DOMAIN, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_NAME, +) +from homeassistant.config_entries import ENTRY_STATE_LOADED +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV + +from tests.common import MockConfigEntry + +MOCK_API = "homeassistant.components.subaru.SubaruAPI." +MOCK_API_CONNECT = f"{MOCK_API}connect" +MOCK_API_IS_PIN_REQUIRED = f"{MOCK_API}is_pin_required" +MOCK_API_TEST_PIN = f"{MOCK_API}test_pin" +MOCK_API_UPDATE_SAVED_PIN = f"{MOCK_API}update_saved_pin" +MOCK_API_GET_VEHICLES = f"{MOCK_API}get_vehicles" +MOCK_API_VIN_TO_NAME = f"{MOCK_API}vin_to_name" +MOCK_API_GET_API_GEN = f"{MOCK_API}get_api_gen" +MOCK_API_GET_EV_STATUS = f"{MOCK_API}get_ev_status" +MOCK_API_GET_RES_STATUS = f"{MOCK_API}get_res_status" +MOCK_API_GET_REMOTE_STATUS = f"{MOCK_API}get_remote_status" +MOCK_API_GET_SAFETY_STATUS = f"{MOCK_API}get_safety_status" +MOCK_API_GET_GET_DATA = f"{MOCK_API}get_data" +MOCK_API_UPDATE = f"{MOCK_API}update" +MOCK_API_FETCH = f"{MOCK_API}fetch" + +TEST_USERNAME = "user@email.com" +TEST_PASSWORD = "password" +TEST_PIN = "1234" +TEST_DEVICE_ID = 1613183362 +TEST_COUNTRY = COUNTRY_USA + +TEST_CREDS = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_COUNTRY: TEST_COUNTRY, +} + +TEST_CONFIG = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_COUNTRY: TEST_COUNTRY, + CONF_PIN: TEST_PIN, + CONF_DEVICE_ID: TEST_DEVICE_ID, +} + +TEST_OPTIONS = { + CONF_UPDATE_ENABLED: True, +} + +TEST_ENTITY_ID = "sensor.test_vehicle_2_odometer" + + +async def setup_subaru_integration( + hass, + vehicle_list=None, + vehicle_data=None, + vehicle_status=None, + connect_effect=None, + fetch_effect=None, +): + """Create Subaru entry.""" + assert await async_setup_component(hass, HA_DOMAIN, {}) + assert await async_setup_component(hass, DOMAIN, {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + options=TEST_OPTIONS, + entry_id=1, + ) + config_entry.add_to_hass(hass) + + with patch( + MOCK_API_CONNECT, + return_value=connect_effect is None, + side_effect=connect_effect, + ), patch(MOCK_API_GET_VEHICLES, return_value=vehicle_list,), patch( + MOCK_API_VIN_TO_NAME, + return_value=vehicle_data[VEHICLE_NAME], + ), patch( + MOCK_API_GET_API_GEN, + return_value=vehicle_data[VEHICLE_API_GEN], + ), patch( + MOCK_API_GET_EV_STATUS, + return_value=vehicle_data[VEHICLE_HAS_EV], + ), patch( + MOCK_API_GET_RES_STATUS, + return_value=vehicle_data[VEHICLE_HAS_REMOTE_START], + ), patch( + MOCK_API_GET_REMOTE_STATUS, + return_value=vehicle_data[VEHICLE_HAS_REMOTE_SERVICE], + ), patch( + MOCK_API_GET_SAFETY_STATUS, + return_value=vehicle_data[VEHICLE_HAS_SAFETY_SERVICE], + ), patch( + MOCK_API_GET_GET_DATA, + return_value=vehicle_status, + ), patch( + MOCK_API_UPDATE, + ), patch( + MOCK_API_FETCH, side_effect=fetch_effect + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture +async def ev_entry(hass): + """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" + entry = await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + ) + assert DOMAIN in hass.config_entries.async_domains() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_get_entry(entry.entry_id) + assert entry.state == ENTRY_STATE_LOADED + return entry diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py new file mode 100644 index 00000000000..676b876652b --- /dev/null +++ b/tests/components/subaru/test_config_flow.py @@ -0,0 +1,250 @@ +"""Tests for the Subaru component config flow.""" +# pylint: disable=redefined-outer-name +from copy import deepcopy +from unittest import mock +from unittest.mock import patch + +import pytest +from subarulink.exceptions import InvalidCredentials, InvalidPIN, SubaruException + +from homeassistant import config_entries +from homeassistant.components.subaru import config_flow +from homeassistant.components.subaru.const import CONF_UPDATE_ENABLED, DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_PIN + +from .conftest import ( + MOCK_API_CONNECT, + MOCK_API_IS_PIN_REQUIRED, + MOCK_API_TEST_PIN, + MOCK_API_UPDATE_SAVED_PIN, + TEST_CONFIG, + TEST_CREDS, + TEST_DEVICE_ID, + TEST_PIN, + TEST_USERNAME, +) + +from tests.common import MockConfigEntry + + +async def test_user_form_init(user_form): + """Test the initial user form for first step of the config flow.""" + expected = { + "data_schema": mock.ANY, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": DOMAIN, + "step_id": "user", + "type": "form", + } + assert expected == user_form + + +async def test_user_form_repeat_identifier(hass, user_form): + """Test we handle repeat identifiers.""" + entry = MockConfigEntry( + domain=DOMAIN, title=TEST_USERNAME, data=TEST_CREDS, options=None + ) + entry.add_to_hass(hass) + + with patch( + MOCK_API_CONNECT, + return_value=True, + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 0 + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_user_form_cannot_connect(hass, user_form): + """Test we handle cannot connect error.""" + with patch( + MOCK_API_CONNECT, + side_effect=SubaruException(None), + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 1 + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_user_form_invalid_auth(hass, user_form): + """Test we handle invalid auth.""" + with patch( + MOCK_API_CONNECT, + side_effect=InvalidCredentials("invalidAccount"), + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_form_pin_not_required(hass, user_form): + """Test successful login when no PIN is required.""" + with patch(MOCK_API_CONNECT, return_value=True,) as mock_connect, patch( + MOCK_API_IS_PIN_REQUIRED, + return_value=False, + ) as mock_is_pin_required: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 2 + assert len(mock_is_pin_required.mock_calls) == 1 + + expected = { + "title": TEST_USERNAME, + "description": None, + "description_placeholders": None, + "flow_id": mock.ANY, + "result": mock.ANY, + "handler": DOMAIN, + "type": "create_entry", + "version": 1, + "data": deepcopy(TEST_CONFIG), + } + expected["data"][CONF_PIN] = None + result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID + assert expected == result + + +async def test_pin_form_init(pin_form): + """Test the pin entry form for second step of the config flow.""" + expected = { + "data_schema": config_flow.PIN_SCHEMA, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": DOMAIN, + "step_id": "pin", + "type": "form", + } + assert expected == pin_form + + +async def test_pin_form_bad_pin_format(hass, pin_form): + """Test we handle invalid pin.""" + with patch(MOCK_API_TEST_PIN,) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: "abcd"} + ) + assert len(mock_test_pin.mock_calls) == 0 + assert len(mock_update_saved_pin.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "bad_pin_format"} + + +async def test_pin_form_success(hass, pin_form): + """Test successful PIN entry.""" + with patch(MOCK_API_TEST_PIN, return_value=True,) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + + assert len(mock_test_pin.mock_calls) == 1 + assert len(mock_update_saved_pin.mock_calls) == 1 + expected = { + "title": TEST_USERNAME, + "description": None, + "description_placeholders": None, + "flow_id": mock.ANY, + "result": mock.ANY, + "handler": DOMAIN, + "type": "create_entry", + "version": 1, + "data": TEST_CONFIG, + } + result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID + assert result == expected + + +async def test_pin_form_incorrect_pin(hass, pin_form): + """Test we handle invalid pin.""" + with patch( + MOCK_API_TEST_PIN, + side_effect=InvalidPIN("invalidPin"), + ) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + assert len(mock_test_pin.mock_calls) == 1 + assert len(mock_update_saved_pin.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "incorrect_pin"} + + +async def test_option_flow_form(options_form): + """Test config flow options form.""" + expected = { + "data_schema": mock.ANY, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": mock.ANY, + "step_id": "init", + "type": "form", + } + assert expected == options_form + + +async def test_option_flow(hass, options_form): + """Test config flow options.""" + result = await hass.config_entries.options.async_configure( + options_form["flow_id"], + user_input={ + CONF_UPDATE_ENABLED: False, + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_UPDATE_ENABLED: False, + } + + +@pytest.fixture +async def user_form(hass): + """Return initial form for Subaru config flow.""" + return await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + +@pytest.fixture +async def pin_form(hass, user_form): + """Return second form (PIN input) for Subaru config flow.""" + with patch(MOCK_API_CONNECT, return_value=True,), patch( + MOCK_API_IS_PIN_REQUIRED, + return_value=True, + ): + return await hass.config_entries.flow.async_configure( + user_form["flow_id"], user_input=TEST_CREDS + ) + + +@pytest.fixture +async def options_form(hass): + """Return options form for Subaru config flow.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + return await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py new file mode 100644 index 00000000000..13b510e8c40 --- /dev/null +++ b/tests/components/subaru/test_init.py @@ -0,0 +1,153 @@ +"""Test Subaru component setup and updates.""" +from unittest.mock import patch + +from subarulink import InvalidCredentials, SubaruException + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.subaru.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component + +from .api_responses import ( + TEST_VIN_1_G1, + TEST_VIN_2_EV, + TEST_VIN_3_G2, + VEHICLE_DATA, + VEHICLE_STATUS_EV, + VEHICLE_STATUS_G2, +) +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_UPDATE, + TEST_ENTITY_ID, + setup_subaru_integration, +) + + +async def test_setup_with_no_config(hass): + """Test DOMAIN is empty if there is no config.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert DOMAIN not in hass.config_entries.async_domains() + + +async def test_setup_ev(hass, ev_entry): + """Test setup with an EV vehicle.""" + check_entry = hass.config_entries.async_get_entry(ev_entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_setup_g2(hass): + """Test setup with a G2 vehcile .""" + entry = await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_3_G2], + vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], + vehicle_status=VEHICLE_STATUS_G2, + ) + check_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_setup_g1(hass): + """Test setup with a G1 vehicle.""" + entry = await setup_subaru_integration( + hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + ) + check_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_unsuccessful_connect(hass): + """Test unsuccessful connect due to connectivity.""" + entry = await setup_subaru_integration( + hass, + connect_effect=SubaruException("Service Unavailable"), + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + ) + check_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_invalid_credentials(hass): + """Test invalid credentials.""" + entry = await setup_subaru_integration( + hass, + connect_effect=InvalidCredentials("Invalid Credentials"), + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + ) + check_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_update_skip_unsubscribed(hass): + """Test update function skips vehicles without subscription.""" + await setup_subaru_integration( + hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + ) + + with patch(MOCK_API_FETCH) as mock_fetch: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_fetch.assert_not_called() + + +async def test_update_disabled(hass, ev_entry): + """Test update function disable option.""" + with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error"),), patch( + MOCK_API_UPDATE, + ) as mock_update: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_update.assert_not_called() + + +async def test_fetch_failed(hass): + """Tests when fetch fails.""" + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + fetch_effect=SubaruException("403 Error"), + ) + + test_entity = hass.states.get(TEST_ENTITY_ID) + assert test_entity.state == "unavailable" + + +async def test_unload_entry(hass, ev_entry): + """Test that entry is unloaded.""" + assert ev_entry.state == ENTRY_STATE_LOADED + assert await hass.config_entries.async_unload(ev_entry.entry_id) + await hass.async_block_till_done() + assert ev_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py new file mode 100644 index 00000000000..4344c147f22 --- /dev/null +++ b/tests/components/subaru/test_sensor.py @@ -0,0 +1,67 @@ +"""Test Subaru sensors.""" +from homeassistant.components.subaru.const import VEHICLE_NAME +from homeassistant.components.subaru.sensor import ( + API_GEN_2_SENSORS, + EV_SENSORS, + SAFETY_SENSORS, + SENSOR_FIELD, + SENSOR_TYPE, +) +from homeassistant.util import slugify +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .api_responses import ( + EXPECTED_STATE_EV_IMPERIAL, + EXPECTED_STATE_EV_METRIC, + EXPECTED_STATE_EV_UNAVAILABLE, + TEST_VIN_2_EV, + VEHICLE_DATA, + VEHICLE_STATUS_EV, +) + +from tests.components.subaru.conftest import setup_subaru_integration + +VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] + + +async def test_sensors_ev_imperial(hass): + """Test sensors supporting imperial units.""" + hass.config.units = IMPERIAL_SYSTEM + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + ) + _assert_data(hass, EXPECTED_STATE_EV_IMPERIAL) + + +async def test_sensors_ev_metric(hass, ev_entry): + """Test sensors supporting metric units.""" + _assert_data(hass, EXPECTED_STATE_EV_METRIC) + + +async def test_sensors_missing_vin_data(hass): + """Test for missing VIN dataset.""" + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=None, + ) + _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) + + +def _assert_data(hass, expected_state): + sensor_list = EV_SENSORS + sensor_list.extend(API_GEN_2_SENSORS) + sensor_list.extend(SAFETY_SENSORS) + expected_states = {} + for item in sensor_list: + expected_states[ + f"sensor.{slugify(f'{VEHICLE_NAME} {item[SENSOR_TYPE]}')}" + ] = expected_state[item[SENSOR_FIELD]] + + for sensor in expected_states: + actual = hass.states.get(sensor) + assert actual.state == expected_states[sensor] diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index d27ede47a63..c5bf8cf28a4 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -46,6 +46,9 @@ async def async_init_integration( # Device Temp Offset device_temp_offset = "tado/device_temp_offset.json" + # Zone Default Overlay + zone_def_overlay = "tado/zone_default_overlay.json" + with requests_mock.mock() as m: m.post("https://auth.tado.com/oauth/token", text=load_fixture(token_fixture)) m.get( @@ -92,6 +95,26 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/capabilities", text=load_fixture(zone_1_capabilities_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/defaultOverlay", + text=load_fixture(zone_def_overlay), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/defaultOverlay", + text=load_fixture(zone_def_overlay), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/defaultOverlay", + text=load_fixture(zone_def_overlay), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/defaultOverlay", + text=load_fixture(zone_def_overlay), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/5/defaultOverlay", + text=load_fixture(zone_def_overlay), + ) m.get( "https://my.tado.com/api/v2/homes/1/zones/5/state", text=load_fixture(zone_5_state_fixture), diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 4035c877bb8..a64c5e9c5e4 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -52,6 +52,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["speed"] is None + assert state.attributes["percentage"] is None assert state.attributes["speed_list"] == ["off", "low", "medium", "high"] assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -60,31 +61,37 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): state = hass.states.get("fan.tasmota") assert state.state == STATE_ON assert state.attributes["speed"] == "low" + assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":2}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON assert state.attributes["speed"] == "medium" + assert state.attributes["percentage"] == 66 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":3}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON assert state.attributes["speed"] == "high" + assert state.attributes["percentage"] == 100 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["speed"] == "off" + assert state.attributes["percentage"] == 0 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":1}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON assert state.attributes["speed"] == "low" + assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["speed"] == "off" + assert state.attributes["percentage"] == 0 async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): @@ -151,6 +158,34 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/FanSpeed", "3", 0, False ) + mqtt_mock.async_publish.reset_mock() + + # Set speed percentage and verify MQTT message is sent + await common.async_set_percentage(hass, "fan.tasmota", 0) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/FanSpeed", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Set speed percentage and verify MQTT message is sent + await common.async_set_percentage(hass, "fan.tasmota", 15) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/FanSpeed", "1", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Set speed percentage and verify MQTT message is sent + await common.async_set_percentage(hass, "fan.tasmota", 50) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/FanSpeed", "2", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Set speed percentage and verify MQTT message is sent + await common.async_set_percentage(hass, "fan.tasmota", 90) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/FanSpeed", "3", 0, False + ) async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): @@ -176,7 +211,7 @@ async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): # Set an unsupported speed and verify MQTT message is not sent with pytest.raises(ValueError) as excinfo: await common.async_set_speed(hass, "fan.tasmota", "no_such_speed") - assert "Unsupported speed no_such_speed" in str(excinfo.value) + assert "no_such_speed" in str(excinfo.value) mqtt_mock.async_publish.assert_not_called() diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index d64e39aacf0..a60f167c38f 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -924,7 +924,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 8;NoDelay;Dimmer 100", + "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", 0, False, ) @@ -934,7 +934,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=255, transition=100) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 40;NoDelay;Dimmer 100", + "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", 0, False, ) @@ -944,7 +944,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=0, transition=100) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 1;NoDelay;Power1 OFF", + "NoDelay;Fade2 1;NoDelay;Speed2 1;NoDelay;Power1 OFF", 0, False, ) @@ -954,7 +954,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 16;NoDelay;Dimmer 50", + "NoDelay;Fade2 1;NoDelay;Speed2 16;NoDelay;Dimmer 50", 0, False, ) @@ -972,7 +972,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_off(hass, "light.test", transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 24;NoDelay;Power1 OFF", + "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 OFF", 0, False, ) @@ -990,7 +990,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_off(hass, "light.test", transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 0;NoDelay;Power1 OFF", + "NoDelay;Fade2 0;NoDelay;Power1 OFF", 0, False, ) @@ -1011,7 +1011,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 24;NoDelay;Power1 ON;NoDelay;Color2 255,0,0", + "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;Color2 255,0,0", 0, False, ) @@ -1032,7 +1032,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 12;NoDelay;Power1 ON;NoDelay;Color2 255,0,0", + "NoDelay;Fade2 1;NoDelay;Speed2 12;NoDelay;Power1 ON;NoDelay;Color2 255,0,0", 0, False, ) @@ -1051,7 +1051,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", color_temp=500, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 24;NoDelay;Power1 ON;NoDelay;CT 500", + "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;CT 500", 0, False, ) @@ -1070,7 +1070,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", color_temp=326, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 40;NoDelay;Power1 ON;NoDelay;CT 326", + "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Power1 ON;NoDelay;CT 326", 0, False, ) @@ -1103,7 +1103,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 8;NoDelay;Dimmer 100", + "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", 0, False, ) @@ -1113,7 +1113,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=255, transition=100) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 40;NoDelay;Dimmer 100", + "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", 0, False, ) @@ -1123,7 +1123,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=0, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 8;NoDelay;Power1 OFF", + "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Power1 OFF", 0, False, ) @@ -1133,7 +1133,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 1;NoDelay;Speed 8;NoDelay;Dimmer 50", + "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 50", 0, False, ) @@ -1143,7 +1143,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await common.async_turn_on(hass, "light.test", brightness=128, transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - "NoDelay;Fade 0;NoDelay;Dimmer 50", + "NoDelay;Fade2 0;NoDelay;Dimmer 50", 0, False, ) diff --git a/tests/components/tcp/test_binary_sensor.py b/tests/components/tcp/test_binary_sensor.py index 2dc16ad79c7..21dd84b1892 100644 --- a/tests/components/tcp/test_binary_sensor.py +++ b/tests/components/tcp/test_binary_sensor.py @@ -1,62 +1,83 @@ """The tests for the TCP binary sensor platform.""" -import unittest -from unittest.mock import Mock, patch +from datetime import timedelta +from unittest.mock import call, patch -from homeassistant.components.tcp import binary_sensor as bin_tcp -import homeassistant.components.tcp.sensor as tcp -from homeassistant.setup import setup_component +import pytest -from tests.common import assert_setup_component, get_test_home_assistant +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.common import assert_setup_component, async_fire_time_changed import tests.components.tcp.test_sensor as test_tcp +BINARY_SENSOR_CONFIG = test_tcp.TEST_CONFIG["sensor"] +TEST_CONFIG = {"binary_sensor": BINARY_SENSOR_CONFIG} +TEST_ENTITY = "binary_sensor.test_name" -class TestTCPBinarySensor(unittest.TestCase): - """Test the TCP Binary Sensor.""" - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +@pytest.fixture(name="mock_socket") +def mock_socket_fixture(): + """Mock the socket.""" + with patch( + "homeassistant.components.tcp.sensor.socket.socket" + ) as mock_socket, patch( + "homeassistant.components.tcp.sensor.select.select", + return_value=(True, False, False), + ): + # yield the return value of the socket context manager + yield mock_socket.return_value.__enter__.return_value - def teardown_method(self, method): - """Stop down everything that was started.""" - self.hass.stop() - def test_setup_platform_valid_config(self): - """Check a valid configuration.""" - with assert_setup_component(0, "binary_sensor"): - assert setup_component(self.hass, "binary_sensor", test_tcp.TEST_CONFIG) +@pytest.fixture +def now(): + """Return datetime UTC now.""" + return utcnow() - def test_setup_platform_invalid_config(self): - """Check the invalid configuration.""" - with assert_setup_component(0): - assert setup_component( - self.hass, - "binary_sensor", - {"binary_sensor": {"platform": "tcp", "porrt": 1234}}, - ) - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_setup_platform_devices(self, mock_update): - """Check the supplied config and call add_entities with sensor.""" - add_entities = Mock() - ret = bin_tcp.setup_platform(None, test_tcp.TEST_CONFIG, add_entities) - assert ret is None - assert add_entities.called - assert isinstance(add_entities.call_args[0][0][0], bin_tcp.TcpBinarySensor) +async def test_setup_platform_valid_config(hass, mock_socket): + """Check a valid configuration.""" + with assert_setup_component(1, "binary_sensor"): + assert await async_setup_component(hass, "binary_sensor", TEST_CONFIG) + await hass.async_block_till_done() - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_is_on_true(self, mock_update): - """Check the return that _state is value_on.""" - sensor = bin_tcp.TcpBinarySensor(self.hass, test_tcp.TEST_CONFIG["sensor"]) - sensor._state = test_tcp.TEST_CONFIG["sensor"][tcp.CONF_VALUE_ON] - print(sensor._state) - assert sensor.is_on - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_is_on_false(self, mock_update): - """Check the return that _state is not the same as value_on.""" - sensor = bin_tcp.TcpBinarySensor(self.hass, test_tcp.TEST_CONFIG["sensor"]) - sensor._state = "{} abc".format( - test_tcp.TEST_CONFIG["sensor"][tcp.CONF_VALUE_ON] +async def test_setup_platform_invalid_config(hass, mock_socket): + """Check the invalid configuration.""" + with assert_setup_component(0): + assert await async_setup_component( + hass, + "binary_sensor", + {"binary_sensor": {"platform": "tcp", "porrt": 1234}}, ) - assert not sensor.is_on + await hass.async_block_till_done() + + +async def test_state(hass, mock_socket, now): + """Check the state and update of the binary sensor.""" + mock_socket.recv.return_value = b"off" + assert await async_setup_component(hass, "binary_sensor", TEST_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + + assert state + assert state.state == STATE_OFF + assert mock_socket.connect.called + assert mock_socket.connect.call_args == call( + (BINARY_SENSOR_CONFIG["host"], BINARY_SENSOR_CONFIG["port"]) + ) + assert mock_socket.send.called + assert mock_socket.send.call_args == call(BINARY_SENSOR_CONFIG["payload"].encode()) + assert mock_socket.recv.called + assert mock_socket.recv.call_args == call(BINARY_SENSOR_CONFIG["buffer_size"]) + + mock_socket.recv.return_value = b"on" + + async_fire_time_changed(hass, now + timedelta(seconds=45)) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + + assert state + assert state.state == STATE_ON diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 8e79d4e514d..b1efef305bf 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -1,16 +1,13 @@ """The tests for the TCP sensor platform.""" from copy import copy -import socket -import unittest -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import call, patch + +import pytest import homeassistant.components.tcp.sensor as tcp -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.template import Template -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant +from tests.common import assert_setup_component TEST_CONFIG = { "sensor": { @@ -21,13 +18,16 @@ TEST_CONFIG = { tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT + 1, tcp.CONF_PAYLOAD: "test_payload", tcp.CONF_UNIT_OF_MEASUREMENT: "test_unit", - tcp.CONF_VALUE_TEMPLATE: Template("test_template"), + tcp.CONF_VALUE_TEMPLATE: "{{ 'test_' + value }}", tcp.CONF_VALUE_ON: "test_on", tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE + 1, } } +SENSOR_TEST_CONFIG = TEST_CONFIG["sensor"] +TEST_ENTITY = "sensor.test_name" KEYS_AND_DEFAULTS = { + tcp.CONF_NAME: tcp.DEFAULT_NAME, tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT, tcp.CONF_UNIT_OF_MEASUREMENT: None, tcp.CONF_VALUE_TEMPLATE: None, @@ -35,229 +35,127 @@ KEYS_AND_DEFAULTS = { tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE, } +socket_test_value = "value" -class TestTCPSensor(unittest.TestCase): - """Test the TCP Sensor.""" - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +@pytest.fixture(name="mock_socket") +def mock_socket_fixture(mock_select): + """Mock socket.""" + with patch("homeassistant.components.tcp.sensor.socket.socket") as mock_socket: + socket_instance = mock_socket.return_value.__enter__.return_value + socket_instance.recv.return_value = socket_test_value.encode() + yield socket_instance - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_setup_platform_valid_config(self, mock_update): - """Check a valid configuration and call add_entities with sensor.""" - with assert_setup_component(0, "sensor"): - assert setup_component(self.hass, "sensor", TEST_CONFIG) +@pytest.fixture(name="mock_select") +def mock_select_fixture(): + """Mock select.""" + with patch( + "homeassistant.components.tcp.sensor.select.select", + return_value=(True, False, False), + ) as mock_select: + yield mock_select - add_entities = Mock() - tcp.setup_platform(None, TEST_CONFIG["sensor"], add_entities) - assert add_entities.called - assert isinstance(add_entities.call_args[0][0][0], tcp.TcpSensor) - def test_setup_platform_invalid_config(self): - """Check an invalid configuration.""" - with assert_setup_component(0): - assert setup_component( - self.hass, "sensor", {"sensor": {"platform": "tcp", "porrt": 1234}} - ) +async def test_setup_platform_valid_config(hass, mock_socket): + """Check a valid configuration and call add_entities with sensor.""" + with assert_setup_component(1, "sensor"): + assert await async_setup_component(hass, "sensor", TEST_CONFIG) + await hass.async_block_till_done() - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_name(self, mock_update): - """Return the name if set in the configuration.""" - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - assert sensor.name == TEST_CONFIG["sensor"][tcp.CONF_NAME] - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_name_not_set(self, mock_update): - """Return the superclass name property if not set in configuration.""" - config = copy(TEST_CONFIG["sensor"]) - del config[tcp.CONF_NAME] - entity = Entity() - sensor = tcp.TcpSensor(self.hass, config) - assert sensor.name == entity.name - - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_state(self, mock_update): - """Return the contents of _state.""" - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - uuid = str(uuid4()) - sensor._state = uuid - assert sensor.state == uuid - - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_unit_of_measurement(self, mock_update): - """Return the configured unit of measurement.""" - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - assert ( - sensor.unit_of_measurement - == TEST_CONFIG["sensor"][tcp.CONF_UNIT_OF_MEASUREMENT] +async def test_setup_platform_invalid_config(hass, mock_socket): + """Check an invalid configuration.""" + with assert_setup_component(0): + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "tcp", "porrt": 1234}} ) + await hass.async_block_till_done() - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_config_valid_keys(self, *args): - """Store valid keys in _config.""" - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - del TEST_CONFIG["sensor"]["platform"] - for key in TEST_CONFIG["sensor"]: - assert key in sensor._config +async def test_state(hass, mock_socket, mock_select): + """Return the contents of _state.""" + assert await async_setup_component(hass, "sensor", TEST_CONFIG) + await hass.async_block_till_done() - def test_validate_config_valid_keys(self): - """Return True when provided with the correct keys.""" - with assert_setup_component(0, "sensor"): - assert setup_component(self.hass, "sensor", TEST_CONFIG) + state = hass.states.get(TEST_ENTITY) - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_config_invalid_keys(self, mock_update): - """Shouldn't store invalid keys in _config.""" - config = copy(TEST_CONFIG["sensor"]) - config.update({"a": "test_a", "b": "test_b", "c": "test_c"}) - sensor = tcp.TcpSensor(self.hass, config) - for invalid_key in "abc": - assert invalid_key not in sensor._config + assert state + assert state.state == "test_value" + assert ( + state.attributes["unit_of_measurement"] + == SENSOR_TEST_CONFIG[tcp.CONF_UNIT_OF_MEASUREMENT] + ) + assert mock_socket.connect.called + assert mock_socket.connect.call_args == call( + (SENSOR_TEST_CONFIG["host"], SENSOR_TEST_CONFIG["port"]) + ) + assert mock_socket.send.called + assert mock_socket.send.call_args == call(SENSOR_TEST_CONFIG["payload"].encode()) + assert mock_select.call_args == call( + [mock_socket], [], [], SENSOR_TEST_CONFIG[tcp.CONF_TIMEOUT] + ) + assert mock_socket.recv.called + assert mock_socket.recv.call_args == call(SENSOR_TEST_CONFIG["buffer_size"]) - def test_validate_config_invalid_keys(self): - """Test with invalid keys plus some extra.""" - config = copy(TEST_CONFIG["sensor"]) - config.update({"a": "test_a", "b": "test_b", "c": "test_c"}) - with assert_setup_component(0, "sensor"): - assert setup_component(self.hass, "sensor", {"tcp": config}) - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_config_uses_defaults(self, mock_update): - """Check if defaults were set.""" - config = copy(TEST_CONFIG["sensor"]) +async def test_config_uses_defaults(hass, mock_socket): + """Check if defaults were set.""" + config = copy(SENSOR_TEST_CONFIG) - for key in KEYS_AND_DEFAULTS: - del config[key] + for key in KEYS_AND_DEFAULTS: + del config[key] - with assert_setup_component(1) as result_config: - assert setup_component(self.hass, "sensor", {"sensor": config}) + with assert_setup_component(1) as result_config: + assert await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() - sensor = tcp.TcpSensor(self.hass, result_config["sensor"][0]) + state = hass.states.get("sensor.tcp_sensor") - for key, default in KEYS_AND_DEFAULTS.items(): - assert sensor._config[key] == default + assert state + assert state.state == "value" - def test_validate_config_missing_defaults(self): - """Return True when defaulted keys are not provided.""" - config = copy(TEST_CONFIG["sensor"]) + for key, default in KEYS_AND_DEFAULTS.items(): + assert result_config["sensor"][0].get(key) == default - for key in KEYS_AND_DEFAULTS: - del config[key] - with assert_setup_component(0, "sensor"): - assert setup_component(self.hass, "sensor", {"tcp": config}) +@pytest.mark.parametrize("sock_attr", ["connect", "send"]) +async def test_update_socket_error(hass, mock_socket, sock_attr): + """Test socket errors during update.""" + socket_method = getattr(mock_socket, sock_attr) + socket_method.side_effect = OSError("Boom") - def test_validate_config_missing_required(self): - """Return False when required config items are missing.""" - for key in TEST_CONFIG["sensor"]: - if key in KEYS_AND_DEFAULTS: - continue - config = copy(TEST_CONFIG["sensor"]) - del config[key] - with assert_setup_component(0, "sensor"): - assert setup_component(self.hass, "sensor", {"tcp": config}) + assert await async_setup_component(hass, "sensor", TEST_CONFIG) + await hass.async_block_till_done() - @patch("homeassistant.components.tcp.sensor.TcpSensor.update") - def test_init_calls_update(self, mock_update): - """Call update() method during __init__().""" - tcp.TcpSensor(self.hass, TEST_CONFIG) - assert mock_update.called + state = hass.states.get(TEST_ENTITY) - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_connects_to_host_and_port(self, mock_select, mock_socket): - """Connect to the configured host and port.""" - tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - mock_socket = mock_socket().__enter__() - assert mock_socket.connect.mock_calls[0][1] == ( - ( - TEST_CONFIG["sensor"][tcp.CONF_HOST], - TEST_CONFIG["sensor"][tcp.CONF_PORT], - ), - ) + assert state + assert state.state == "unknown" - @patch("socket.socket.connect", side_effect=socket.error()) - def test_update_returns_if_connecting_fails(self, *args): - """Return if connecting to host fails.""" - with patch("homeassistant.components.tcp.sensor.TcpSensor.update"): - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - assert sensor.update() is None - @patch("socket.socket.connect") - @patch("socket.socket.send", side_effect=socket.error()) - def test_update_returns_if_sending_fails(self, *args): - """Return if sending fails.""" - with patch("homeassistant.components.tcp.sensor.TcpSensor.update"): - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - assert sensor.update() is None +async def test_update_select_fails(hass, mock_socket, mock_select): + """Test select fails to return a socket for reading.""" + mock_select.return_value = (False, False, False) - @patch("socket.socket.connect") - @patch("socket.socket.send") - @patch("select.select", return_value=(False, False, False)) - def test_update_returns_if_select_fails(self, *args): - """Return if select fails to return a socket.""" - with patch("homeassistant.components.tcp.sensor.TcpSensor.update"): - sensor = tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - assert sensor.update() is None + assert await async_setup_component(hass, "sensor", TEST_CONFIG) + await hass.async_block_till_done() - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_sends_payload(self, mock_select, mock_socket): - """Send the configured payload as bytes.""" - tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - mock_socket = mock_socket().__enter__() - mock_socket.send.assert_called_with( - TEST_CONFIG["sensor"][tcp.CONF_PAYLOAD].encode() - ) + state = hass.states.get(TEST_ENTITY) - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_calls_select_with_timeout(self, mock_select, mock_socket): - """Provide the timeout argument to select.""" - tcp.TcpSensor(self.hass, TEST_CONFIG["sensor"]) - mock_socket = mock_socket().__enter__() - mock_select.assert_called_with( - [mock_socket], [], [], TEST_CONFIG["sensor"][tcp.CONF_TIMEOUT] - ) + assert state + assert state.state == "unknown" - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_receives_packet_and_sets_as_state(self, mock_select, mock_socket): - """Test the response from the socket and set it as the state.""" - test_value = "test_value" - mock_socket = mock_socket().__enter__() - mock_socket.recv.return_value = test_value.encode() - config = copy(TEST_CONFIG["sensor"]) - del config[tcp.CONF_VALUE_TEMPLATE] - sensor = tcp.TcpSensor(self.hass, config) - assert sensor._state == test_value - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_renders_value_in_template(self, mock_select, mock_socket): - """Render the value in the provided template.""" - test_value = "test_value" - mock_socket = mock_socket().__enter__() - mock_socket.recv.return_value = test_value.encode() - config = copy(TEST_CONFIG["sensor"]) - config[tcp.CONF_VALUE_TEMPLATE] = Template("{{ value }} {{ 1+1 }}") - sensor = tcp.TcpSensor(self.hass, config) - assert sensor._state == "%s 2" % test_value +async def test_update_returns_if_template_render_fails(hass, mock_socket): + """Return None if rendering the template fails.""" + config = copy(SENSOR_TEST_CONFIG) + config[tcp.CONF_VALUE_TEMPLATE] = "{{ value / 0 }}" - @patch("socket.socket") - @patch("select.select", return_value=(True, False, False)) - def test_update_returns_if_template_render_fails(self, mock_select, mock_socket): - """Return None if rendering the template fails.""" - test_value = "test_value" - mock_socket = mock_socket().__enter__() - mock_socket.recv.return_value = test_value.encode() - config = copy(TEST_CONFIG["sensor"]) - config[tcp.CONF_VALUE_TEMPLATE] = Template("{{ this won't work") - sensor = tcp.TcpSensor(self.hass, config) - assert sensor.update() is None + assert await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + + assert state + assert state.state == "unknown" diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 8e7c519def9..2b9059017c6 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -6,12 +6,15 @@ from homeassistant import setup from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE @@ -25,6 +28,10 @@ _STATE_INPUT_BOOLEAN = "input_boolean.state" _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" # Represent for fan's speed _SPEED_INPUT_SELECT = "input_select.speed" +# Represent for fan's preset mode +_PRESET_MODE_INPUT_SELECT = "input_select.preset_mode" +# Represent for fan's speed percentage +_PERCENTAGE_INPUT_NUMBER = "input_number.percentage" # Represent for fan's oscillating _OSC_INPUT = "input_select.osc" # Represent for fan's direction @@ -62,7 +69,7 @@ async def test_missing_optional_config(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, None, None, None, None) async def test_missing_value_template_config(hass, calls): @@ -191,9 +198,16 @@ async def test_templates_with_entities(hass, calls): "fans": { "test_fan": { "value_template": value_template, + "percentage_template": "{{ states('input_number.percentage') }}", "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", + "speed_count": "3", + "set_percentage": { + "service": "script.fans_set_speed", + "data_template": {"percentage": "{{ percentage }}"}, + }, "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, } @@ -206,7 +220,7 @@ async def test_templates_with_entities(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, True) hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) @@ -214,7 +228,128 @@ async def test_templates_with_entities(hass, calls): hass.states.async_set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 33) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_LOW, 33, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 100) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_HIGH, 100, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, "dog") + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None) + + +async def test_templates_with_entities_and_invalid_percentage(hass, calls): + """Test templates with values from other entities.""" + hass.states.async_set("sensor.percentage", "0") + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ states('sensor.percentage') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "33") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) + + hass.states.async_set("sensor.percentage", "invalid") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "5000") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "100") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + + hass.states.async_set("sensor.percentage", "0") + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + +async def test_templates_with_entities_and_preset_modes(hass, calls): + """Test templates with values from other entities.""" + hass.states.async_set("sensor.preset_mode", "0") + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "preset_modes": ["auto", "smart"], + "preset_mode_template": "{{ states('sensor.preset_mode') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None, None, None) + + hass.states.async_set("sensor.preset_mode", "invalid") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None, None, None) + + hass.states.async_set("sensor.preset_mode", "auto") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, "auto", None, None, None, "auto") + + hass.states.async_set("sensor.preset_mode", "smart") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, "smart", None, None, None, "smart") + + hass.states.async_set("sensor.preset_mode", "invalid") + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, None, None, None) async def test_template_with_unavailable_entities(hass, calls): @@ -272,7 +407,7 @@ async def test_template_with_unavailable_parameters(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_availability_template_with_entities(hass, calls): @@ -346,7 +481,7 @@ async def test_templates_with_valid_values(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) async def test_templates_invalid_values(hass, calls): @@ -376,7 +511,7 @@ async def test_templates_invalid_values(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) async def test_invalid_availability_template_keeps_component_available(hass, caplog): @@ -394,6 +529,7 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "value_template": "{{ 'on' }}", "availability_template": "{{ x - 12 }}", "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", "turn_on": {"service": "script.fan_on"}, @@ -427,14 +563,14 @@ async def test_on_off(hass, calls): # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) # Turn off fan await common.async_turn_off(hass, _TEST_FAN) # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) async def test_on_with_speed(hass, calls): @@ -446,13 +582,13 @@ async def test_on_with_speed(hass, calls): # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) async def test_set_speed(hass, calls): """Test set valid speed.""" - await _register_components(hass) + await _register_components(hass, preset_modes=["auto", "smart"]) # Turn on fan await common.async_turn_on(hass, _TEST_FAN) @@ -462,14 +598,95 @@ async def test_set_speed(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) # Set fan's speed to medium await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - _verify(hass, STATE_ON, SPEED_MEDIUM, None, None) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + + # Set fan's speed to off + await common.async_set_speed(hass, _TEST_FAN, SPEED_OFF) + + # verify + assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_OFF + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + +async def test_set_percentage(hass, calls): + """Test set valid speed percentage.""" + await _register_components(hass) + + # Turn on fan + await common.async_turn_on(hass, _TEST_FAN) + + # Set fan's percentage speed to 100 + await common.async_set_percentage(hass, _TEST_FAN, 100) + + # verify + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + + # Set fan's percentage speed to 66 + await common.async_set_percentage(hass, _TEST_FAN, 66) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 + + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + + # Set fan's percentage speed to 0 + await common.async_set_percentage(hass, _TEST_FAN, 0) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + # Set fan's percentage speed to 50 + await common.async_turn_on(hass, _TEST_FAN, percentage=50) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + + _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) + + +async def test_increase_decrease_speed(hass, calls): + """Test set valid increase and derease speed.""" + await _register_components(hass) + + # Turn on fan + await common.async_turn_on(hass, _TEST_FAN) + + # Set fan's percentage speed to 100 + await common.async_set_percentage(hass, _TEST_FAN, 100) + + # verify + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + + # Set fan's percentage speed to 66 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 + + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + + # Set fan's percentage speed to 33 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) + + # Set fan's percentage speed to 0 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + # Set fan's percentage speed to 33 + await common.async_increase_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) async def test_set_invalid_speed_from_initial_stage(hass, calls): @@ -484,7 +701,7 @@ async def test_set_invalid_speed_from_initial_stage(hass, calls): # verify speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_speed(hass, calls): @@ -499,14 +716,14 @@ async def test_set_invalid_speed(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) # Set fan's speed to 'invalid' await common.async_set_speed(hass, _TEST_FAN, "invalid") # verify speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) async def test_custom_speed_list(hass, calls): @@ -521,14 +738,48 @@ async def test_custom_speed_list(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", None, None) + _verify(hass, STATE_ON, "1", 33, None, None, None) # Set fan's speed to 'medium' which is invalid await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) # verify that speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", None, None) + _verify(hass, STATE_ON, "1", 33, None, None, None) + + +async def test_preset_modes(hass, calls): + """Test preset_modes.""" + await _register_components( + hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] + ) + + # Turn on fan + await common.async_turn_on(hass, _TEST_FAN) + + # Set fan's preset_mode to "auto" + await common.async_set_preset_mode(hass, _TEST_FAN, "auto") + + # verify + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + + # Set fan's preset_mode to "smart" + await common.async_set_preset_mode(hass, _TEST_FAN, "smart") + + # Verify fan's preset_mode is "smart" + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" + + # Set fan's preset_mode to "invalid" + await common.async_set_preset_mode(hass, _TEST_FAN, "invalid") + + # Verify fan's preset_mode is still "smart" + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" + + # Set fan's preset_mode to "auto" + await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") + + # verify + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" async def test_set_osc(hass, calls): @@ -543,14 +794,14 @@ async def test_set_osc(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) # Set fan's osc to False await common.async_oscillate(hass, _TEST_FAN, False) # verify assert hass.states.get(_OSC_INPUT).state == "False" - _verify(hass, STATE_ON, None, False, None) + _verify(hass, STATE_ON, None, 0, False, None, None) async def test_set_invalid_osc_from_initial_state(hass, calls): @@ -566,7 +817,7 @@ async def test_set_invalid_osc_from_initial_state(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_osc(hass, calls): @@ -581,7 +832,7 @@ async def test_set_invalid_osc(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) # Set fan's osc to None with pytest.raises(vol.Invalid): @@ -589,7 +840,7 @@ async def test_set_invalid_osc(hass, calls): # verify osc is unchanged assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) async def test_set_direction(hass, calls): @@ -604,14 +855,14 @@ async def test_set_direction(hass, calls): # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) # Set fan's direction to reverse await common.async_set_direction(hass, _TEST_FAN, DIRECTION_REVERSE) # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_REVERSE - _verify(hass, STATE_ON, None, None, DIRECTION_REVERSE) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_REVERSE, None) async def test_set_invalid_direction_from_initial_stage(hass, calls): @@ -626,7 +877,7 @@ async def test_set_invalid_direction_from_initial_stage(hass, calls): # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_direction(hass, calls): @@ -641,36 +892,61 @@ async def test_set_invalid_direction(hass, calls): # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) # Set fan's direction to 'invalid' await common.async_set_direction(hass, _TEST_FAN, "invalid") # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) def _verify( - hass, expected_state, expected_speed, expected_oscillating, expected_direction + hass, + expected_state, + expected_speed, + expected_percentage, + expected_oscillating, + expected_direction, + expected_preset_mode, ): """Verify fan's state, speed and osc.""" state = hass.states.get(_TEST_FAN) attributes = state.attributes assert state.state == str(expected_state) assert attributes.get(ATTR_SPEED) == expected_speed + assert attributes.get(ATTR_PERCENTAGE) == expected_percentage assert attributes.get(ATTR_OSCILLATING) == expected_oscillating assert attributes.get(ATTR_DIRECTION) == expected_direction + assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def _register_components(hass, speed_list=None): +async def _register_components(hass, speed_list=None, preset_modes=None): """Register basic components for testing.""" with assert_setup_component(1, "input_boolean"): assert await setup.async_setup_component( hass, "input_boolean", {"input_boolean": {"state": None}} ) - with assert_setup_component(3, "input_select"): + with assert_setup_component(1, "input_number"): + assert await setup.async_setup_component( + hass, + "input_number", + { + "input_number": { + "percentage": { + "min": 0.0, + "max": 100.0, + "name": "Percentage", + "step": 1.0, + "mode": "slider", + } + } + }, + ) + + with assert_setup_component(4, "input_select"): assert await setup.async_setup_component( hass, "input_select", @@ -680,14 +956,21 @@ async def _register_components(hass, speed_list=None): "name": "Speed", "options": [ "", + SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, "1", "2", "3", + "auto", + "smart", ], }, + "preset_mode": { + "name": "Preset Mode", + "options": ["auto", "smart"], + }, "osc": {"name": "oscillating", "options": ["", "True", "False"]}, "direction": { "name": "Direction", @@ -709,6 +992,8 @@ async def _register_components(hass, speed_list=None): test_fan_config = { "value_template": value_template, "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", "turn_on": { @@ -726,6 +1011,20 @@ async def _register_components(hass, speed_list=None): "option": "{{ speed }}", }, }, + "set_preset_mode": { + "service": "input_select.select_option", + "data_template": { + "entity_id": _PRESET_MODE_INPUT_SELECT, + "option": "{{ preset_mode }}", + }, + }, + "set_percentage": { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": "{{ percentage }}", + }, + }, "set_oscillating": { "service": "input_select.select_option", "data_template": { @@ -745,6 +1044,9 @@ async def _register_components(hass, speed_list=None): if speed_list: test_fan_config["speeds"] = speed_list + if preset_modes: + test_fan_config["preset_modes"] = preset_modes + assert await setup.async_setup_component( hass, "fan", diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 7f560fa0abb..9d014f86a36 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -3,6 +3,8 @@ from asyncio import Event from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_from_config_dict from homeassistant.components import sensor from homeassistant.const import ( @@ -403,6 +405,7 @@ async def test_setup_valid_device_class(hass): assert "device_class" not in state.attributes +@pytest.mark.parametrize("load_registries", [False]) async def test_creating_sensor_loads_group(hass): """Test setting up template sensor loads group component first.""" order = [] diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index de4974cb1b6..55311005201 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -43,13 +43,15 @@ async def test_if_fires_on_change_bool(hass, calls): automation.DOMAIN: { "trigger": { "platform": "template", - "value_template": "{{ states.test.entity.state and true }}", + "value_template": '{{ states.test.entity.state == "world" and true }}', }, "action": {"service": "test.automation"}, } }, ) + assert len(calls) == 0 + hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 1 @@ -75,13 +77,15 @@ async def test_if_fires_on_change_str(hass, calls): automation.DOMAIN: { "trigger": { "platform": "template", - "value_template": '{{ states.test.entity.state and "true" }}', + "value_template": '{{ states.test.entity.state == "world" and "true" }}', }, "action": {"service": "test.automation"}, } }, ) + assert len(calls) == 0 + hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 1 @@ -96,7 +100,7 @@ async def test_if_fires_on_change_str_crazy(hass, calls): automation.DOMAIN: { "trigger": { "platform": "template", - "value_template": '{{ states.test.entity.state and "TrUE" }}', + "value_template": '{{ states.test.entity.state == "world" and "TrUE" }}', }, "action": {"service": "test.automation"}, } @@ -108,6 +112,100 @@ async def test_if_fires_on_change_str_crazy(hass, calls): assert len(calls) == 1 +async def test_if_not_fires_when_true_at_setup(hass, calls): + """Test for not firing during startup.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "hello" }}', + }, + "action": {"service": "test.automation"}, + } + }, + ) + + assert len(calls) == 0 + + hass.states.async_set("test.entity", "hello", force_update=True) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_if_not_fires_when_true_at_setup_variables(hass, calls): + """Test for not firing during startup + trigger_variables.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger_variables": {"entity": "test.entity"}, + "trigger": { + "platform": "template", + "value_template": '{{ is_state(entity|default("test.entity2"), "hello") }}', + }, + "action": {"service": "test.automation"}, + } + }, + ) + + assert len(calls) == 0 + + # Assert that the trigger doesn't fire immediately when it's setup + # If trigger_variable 'entity' is not passed to initial check at setup, the + # trigger will immediately fire + hass.states.async_set("test.entity", "hello", force_update=True) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.states.async_set("test.entity", "goodbye", force_update=True) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Assert that the trigger fires after state change + # If trigger_variable 'entity' is not passed to the template trigger, the + # trigger will never fire because it falls back to 'test.entity2' + hass.states.async_set("test.entity", "hello", force_update=True) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_not_fires_because_fail(hass, calls): + """Test for not firing after TemplateError.""" + hass.states.async_set("test.number", "1") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ 84 / states.test.number.state|int == 42 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + assert len(calls) == 0 + + hass.states.async_set("test.number", "2") + await hass.async_block_till_done() + assert len(calls) == 1 + + hass.states.async_set("test.number", "0") + await hass.async_block_till_done() + assert len(calls) == 1 + + hass.states.async_set("test.number", "2") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_not_fires_on_change_bool(hass, calls): """Test for not firing on boolean change.""" assert await async_setup_component( @@ -117,7 +215,7 @@ async def test_if_not_fires_on_change_bool(hass, calls): automation.DOMAIN: { "trigger": { "platform": "template", - "value_template": "{{ states.test.entity.state and false }}", + "value_template": '{{ states.test.entity.state == "world" and false }}', }, "action": {"service": "test.automation"}, } @@ -198,7 +296,7 @@ async def test_if_fires_on_two_change(hass, calls): automation.DOMAIN: { "trigger": { "platform": "template", - "value_template": "{{ states.test.entity.state and true }}", + "value_template": "{{ states.test.entity.state == 'world' }}", }, "action": {"service": "test.automation"}, } diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py new file mode 100644 index 00000000000..155c7f33a9f --- /dev/null +++ b/tests/components/template/test_weather.py @@ -0,0 +1,56 @@ +"""The tests for the Template Weather platform.""" +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_SPEED, + DOMAIN, +) +from homeassistant.setup import async_setup_component + + +async def test_template_state_text(hass): + """Test the state text of a template.""" + await async_setup_component( + hass, + DOMAIN, + { + "weather": [ + {"weather": {"platform": "demo"}}, + { + "platform": "template", + "name": "test", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.demo.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + }, + ] + }, + ) + await hass.async_block_till_done() + + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("sensor.temperature", 22.3) + await hass.async_block_till_done() + hass.states.async_set("sensor.humidity", 60) + await hass.async_block_till_done() + hass.states.async_set("sensor.pressure", 1000) + await hass.async_block_till_done() + hass.states.async_set("sensor.windspeed", 20) + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state is not None + + assert state.state == "sunny" + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == 22.3 + assert data.get(ATTR_WEATHER_HUMIDITY) == 60 + assert data.get(ATTR_WEATHER_PRESSURE) == 1000 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 20 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 17fa244f9b2..d4285c07425 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -3,7 +3,7 @@ from unittest.mock import patch from total_connect_client import TotalConnectClient -from homeassistant.components.totalconnect import DOMAIN +from homeassistant.components.totalconnect.const import CONF_USERCODES, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.setup import async_setup_component @@ -29,13 +29,19 @@ USER = { } RESPONSE_AUTHENTICATE = { - "ResultCode": 0, + "ResultCode": TotalConnectClient.TotalConnectClient.SUCCESS, "SessionID": 1, "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } +RESPONSE_AUTHENTICATE_FAILED = { + "ResultCode": TotalConnectClient.TotalConnectClient.BAD_USER_OR_PASSWORD, + "ResultData": "test bad authentication", +} + + PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": TotalConnectClient.TotalConnectLocation.DISARMED, @@ -101,6 +107,32 @@ RESPONSE_DISARM_FAILURE = { "ResultCode": TotalConnectClient.TotalConnectClient.COMMAND_FAILED, "ResultData": "Command Failed", } +RESPONSE_USER_CODE_INVALID = { + "ResultCode": TotalConnectClient.TotalConnectClient.USER_CODE_INVALID, + "ResultData": "testing user code invalid", +} +RESPONSE_SUCCESS = {"ResultCode": TotalConnectClient.TotalConnectClient.SUCCESS} + +USERNAME = "username@me.com" +PASSWORD = "password" +USERCODES = {123456: "7890"} +CONFIG_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_USERCODES: USERCODES, +} +CONFIG_DATA_NO_USERCODES = {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + + +USERNAME = "username@me.com" +PASSWORD = "password" +USERCODES = {123456: "7890"} +CONFIG_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_USERCODES: USERCODES, +} +CONFIG_DATA_NO_USERCODES = {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} async def setup_platform(hass, platform): @@ -108,7 +140,7 @@ async def setup_platform(hass, platform): # first set up a config entry and add it to hass mock_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + data=CONFIG_DATA, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index bc90c1aae2a..ba929c0bc54 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, ) +from homeassistant.exceptions import HomeAssistantError from .common import ( RESPONSE_ARM_FAILURE, @@ -23,6 +24,7 @@ from .common import ( RESPONSE_DISARM_FAILURE, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED, + RESPONSE_USER_CODE_INVALID, setup_platform, ) @@ -72,12 +74,31 @@ async def test_arm_home_failure(hass): await setup_platform(hass, ALARM_DOMAIN) assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state - with pytest.raises(Exception) as e: + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{e.value}" == "TotalConnect failed to arm home test." + assert f"{err.value}" == "TotalConnect failed to arm home test." + assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state + + +async def test_arm_home_invalid_usercode(hass): + """Test arm home method with invalid usercode.""" + responses = [RESPONSE_DISARMED, RESPONSE_USER_CODE_INVALID, RESPONSE_DISARMED] + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.TotalConnectClient.request", + side_effect=responses, + ): + await setup_platform(hass, ALARM_DOMAIN) + assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect failed to arm home test." assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state @@ -108,12 +129,12 @@ async def test_arm_away_failure(hass): await setup_platform(hass, ALARM_DOMAIN) assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state - with pytest.raises(Exception) as e: + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{e.value}" == "TotalConnect failed to arm away test." + assert f"{err.value}" == "TotalConnect failed to arm away test." assert STATE_ALARM_DISARMED == hass.states.get(ENTITY_ID).state @@ -144,10 +165,29 @@ async def test_disarm_failure(hass): await setup_platform(hass, ALARM_DOMAIN) assert STATE_ALARM_ARMED_AWAY == hass.states.get(ENTITY_ID).state - with pytest.raises(Exception) as e: + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{e.value}" == "TotalConnect failed to disarm test." + assert f"{err.value}" == "TotalConnect failed to disarm test." + assert STATE_ALARM_ARMED_AWAY == hass.states.get(ENTITY_ID).state + + +async def test_disarm_invalid_usercode(hass): + """Test disarm method failure.""" + responses = [RESPONSE_ARMED_AWAY, RESPONSE_USER_CODE_INVALID, RESPONSE_ARMED_AWAY] + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.TotalConnectClient.request", + side_effect=responses, + ): + await setup_platform(hass, ALARM_DOMAIN) + assert STATE_ALARM_ARMED_AWAY == hass.states.get(ENTITY_ID).state + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect failed to disarm test." assert STATE_ALARM_ARMED_AWAY == hass.states.get(ENTITY_ID).state diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index a1aa8780cfb..5d1723a835e 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -1,78 +1,97 @@ -"""Tests for the iCloud config flow.""" +"""Tests for the TotalConnect config flow.""" from unittest.mock import patch from homeassistant import data_entry_flow -from homeassistant.components.totalconnect.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.totalconnect.const import CONF_LOCATION, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD + +from .common import ( + CONFIG_DATA, + CONFIG_DATA_NO_USERCODES, + RESPONSE_AUTHENTICATE, + RESPONSE_DISARMED, + RESPONSE_SUCCESS, + RESPONSE_USER_CODE_INVALID, + USERNAME, +) from tests.common import MockConfigEntry -USERNAME = "username@me.com" -PASSWORD = "password" - async def test_user(hass): - """Test user config.""" - # no data provided so show the form + """Test user step.""" + # user starts with no data entered, so show the user form result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, + data=None, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # now data is provided, so check if login is correct and create the entry - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" - ) as client_mock: - client_mock.return_value.is_valid_credentials.return_value = True + +async def test_user_show_locations(hass): + """Test user locations form.""" + # user/pass provided, so check if valid then ask for usercodes on locations form + responses = [ + RESPONSE_AUTHENTICATE, + RESPONSE_DISARMED, + RESPONSE_USER_CODE_INVALID, + RESPONSE_SUCCESS, + ] + + with patch("zeep.Client", autospec=True), patch( + "homeassistant.components.totalconnect.TotalConnectClient.TotalConnectClient.request", + side_effect=responses, + ) as mock_request, patch( + "homeassistant.components.totalconnect.TotalConnectClient.TotalConnectClient.get_zone_details", + return_value=True, + ), patch( + "homeassistant.components.totalconnect.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data=CONFIG_DATA_NO_USERCODES, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + # first it should show the locations form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "locations" + # client should have sent two requests, authenticate and get status + assert mock_request.call_count == 2 - -async def test_import(hass): - """Test import step with good username and password.""" - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" - ) as client_mock: - client_mock.return_value.is_valid_credentials.return_value = True - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + # user enters an invalid usercode + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: "bad"}, ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "locations" + # client should have sent 3rd request to validate usercode + assert mock_request.call_count == 3 - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + # user enters a valid usercode + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_LOCATION: "7890"}, + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + # client should have sent another request to validate usercode + assert mock_request.call_count == 4 async def test_abort_if_already_setup(hass): """Test abort if the account is already setup.""" MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data=CONFIG_DATA, unique_id=USERNAME, ).add_to_hass(hass) - # Should fail, same USERNAME (import) - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" - ) as client_mock: - client_mock.return_value.is_valid_credentials.return_value = True - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - # Should fail, same USERNAME (flow) with patch( "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" @@ -81,7 +100,7 @@ async def test_abort_if_already_setup(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data=CONFIG_DATA, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -97,8 +116,51 @@ async def test_login_failed(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data=CONFIG_DATA, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reauth(hass): + """Test reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + unique_id=USERNAME, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + # first test with an invalid password + client_mock.return_value.is_valid_credentials.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # now test with the password valid + client_mock.return_value.is_valid_credentials.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/totalconnect/test_init.py b/tests/components/totalconnect/test_init.py new file mode 100644 index 00000000000..b8024dbe70d --- /dev/null +++ b/tests/components/totalconnect/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the TotalConnect init process.""" +from unittest.mock import patch + +from homeassistant.components.totalconnect.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_SETUP_ERROR +from homeassistant.setup import async_setup_component + +from .common import CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def test_reauth_started(hass): + """Test that reauth is started when we have login errors.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.TotalConnectClient", + autospec=True, + ) as mock_client: + mock_client.return_value.is_valid_credentials.return_value = False + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert mock_entry.state == ENTRY_STATE_SETUP_ERROR diff --git a/tests/components/tuya/common.py b/tests/components/tuya/common.py new file mode 100644 index 00000000000..8dcef136b7f --- /dev/null +++ b/tests/components/tuya/common.py @@ -0,0 +1,75 @@ +"""Test code shared between test files.""" + +from tuyaha.devices import climate, light, switch + +CLIMATE_ID = "1" +CLIMATE_DATA = { + "data": {"state": "true", "temp_unit": climate.UNIT_CELSIUS}, + "id": CLIMATE_ID, + "ha_type": "climate", + "name": "TestClimate", + "dev_type": "climate", +} + +LIGHT_ID = "2" +LIGHT_DATA = { + "data": {"state": "true"}, + "id": LIGHT_ID, + "ha_type": "light", + "name": "TestLight", + "dev_type": "light", +} + +SWITCH_ID = "3" +SWITCH_DATA = { + "data": {"state": True}, + "id": SWITCH_ID, + "ha_type": "switch", + "name": "TestSwitch", + "dev_type": "switch", +} + +LIGHT_ID_FAKE1 = "9998" +LIGHT_DATA_FAKE1 = { + "data": {"state": "true"}, + "id": LIGHT_ID_FAKE1, + "ha_type": "light", + "name": "TestLightFake1", + "dev_type": "light", +} + +LIGHT_ID_FAKE2 = "9999" +LIGHT_DATA_FAKE2 = { + "data": {"state": "true"}, + "id": LIGHT_ID_FAKE2, + "ha_type": "light", + "name": "TestLightFake2", + "dev_type": "light", +} + +TUYA_DEVICES = [ + climate.TuyaClimate(CLIMATE_DATA, None), + light.TuyaLight(LIGHT_DATA, None), + switch.TuyaSwitch(SWITCH_DATA, None), + light.TuyaLight(LIGHT_DATA_FAKE1, None), + light.TuyaLight(LIGHT_DATA_FAKE2, None), +] + + +class MockTuya: + """Mock for Tuya devices.""" + + def get_all_devices(self): + """Return all configured devices.""" + return TUYA_DEVICES + + def get_device_by_id(self, dev_id): + """Return configured device with dev id.""" + if dev_id == LIGHT_ID_FAKE1: + return None + if dev_id == LIGHT_ID_FAKE2: + return switch.TuyaSwitch(SWITCH_DATA, None) + for device in TUYA_DEVICES: + if device.object_id() == dev_id: + return device + return None diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 0055b451e1a..ede6e5ac1db 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -2,11 +2,47 @@ from unittest.mock import Mock, patch import pytest +from tuyaha.devices.climate import STEP_HALVES from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.tuya.const import CONF_COUNTRYCODE, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.tuya.config_flow import ( + CONF_LIST_DEVICES, + ERROR_DEV_MULTI_TYPE, + ERROR_DEV_NOT_CONFIG, + ERROR_DEV_NOT_FOUND, + RESULT_AUTH_FAILED, + RESULT_CONN_ERROR, + RESULT_SINGLE_INSTANCE, +) +from homeassistant.components.tuya.const import ( + CONF_BRIGHTNESS_RANGE_MODE, + CONF_COUNTRYCODE, + CONF_CURR_TEMP_DIVIDER, + CONF_DISCOVERY_INTERVAL, + CONF_MAX_KELVIN, + CONF_MAX_TEMP, + CONF_MIN_KELVIN, + CONF_MIN_TEMP, + CONF_QUERY_DEVICE, + CONF_QUERY_INTERVAL, + CONF_SET_TEMP_DIVIDED, + CONF_SUPPORT_COLOR, + CONF_TEMP_DIVIDER, + CONF_TEMP_STEP_OVERRIDE, + CONF_TUYA_MAX_COLTEMP, + DOMAIN, + TUYA_DATA, +) +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PLATFORM, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + TEMP_CELSIUS, +) + +from .common import CLIMATE_ID, LIGHT_ID, LIGHT_ID_FAKE1, LIGHT_ID_FAKE2, MockTuya from tests.common import MockConfigEntry @@ -30,9 +66,15 @@ def tuya_fixture() -> Mock: yield tuya +@pytest.fixture(name="tuya_setup", autouse=True) +def tuya_setup_fixture(): + """Mock tuya entry setup.""" + with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): + yield + + async def test_user(hass, tuya): """Test user config.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -40,15 +82,10 @@ async def test_user(hass, tuya): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.tuya.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.tuya.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_USER_DATA - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_USER_DATA + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == USERNAME @@ -58,26 +95,15 @@ async def test_user(hass, tuya): assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM assert not result["result"].unique_id - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_import(hass, tuya): """Test import step.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - with patch( - "homeassistant.components.tuya.async_setup", - return_value=True, - ) as mock_setup, patch( - "homeassistant.components.tuya.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=TUYA_USER_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TUYA_USER_DATA, + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == USERNAME @@ -87,9 +113,6 @@ async def test_import(hass, tuya): assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM assert not result["result"].unique_id - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - async def test_abort_if_already_setup(hass, tuya): """Test we abort if Tuya is already setup.""" @@ -101,7 +124,7 @@ async def test_abort_if_already_setup(hass, tuya): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == RESULT_SINGLE_INSTANCE # Should fail, config exist (flow) result = await hass.config_entries.flow.async_init( @@ -109,7 +132,7 @@ async def test_abort_if_already_setup(hass, tuya): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == RESULT_SINGLE_INSTANCE async def test_abort_on_invalid_credentials(hass, tuya): @@ -121,14 +144,14 @@ async def test_abort_on_invalid_credentials(hass, tuya): ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": RESULT_AUTH_FAILED} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_auth" + assert result["reason"] == RESULT_AUTH_FAILED async def test_abort_on_connection_error(hass, tuya): @@ -140,11 +163,152 @@ async def test_abort_on_connection_error(hass, tuya): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == RESULT_CONN_ERROR result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == RESULT_CONN_ERROR + + +async def test_options_flow(hass): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TUYA_USER_DATA, + ) + config_entry.add_to_hass(hass) + + # Set up the integration to make sure the config flow module is loaded. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Unload the integration to prepare for the test. + with patch("homeassistant.components.tuya.async_unload_entry", return_value=True): + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Test check for integration not loaded + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == RESULT_CONN_ERROR + + # Load integration and enter options + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + hass.data[DOMAIN] = {TUYA_DATA: MockTuya()} + 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" + + # Test dev not found error + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE1}"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": ERROR_DEV_NOT_FOUND} + + # Test dev type error + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE2}"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": ERROR_DEV_NOT_CONFIG} + + # Test multi dev error + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}", f"light-{LIGHT_ID}"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": ERROR_DEV_MULTI_TYPE} + + # Test climate options form + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}"]} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + CONF_TEMP_DIVIDER: 10, + CONF_CURR_TEMP_DIVIDER: 5, + CONF_SET_TEMP_DIVIDED: False, + CONF_TEMP_STEP_OVERRIDE: STEP_HALVES, + CONF_MIN_TEMP: 12, + CONF_MAX_TEMP: 22, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Test light options form + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID}"]} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SUPPORT_COLOR: True, + CONF_BRIGHTNESS_RANGE_MODE: 1, + CONF_MIN_KELVIN: 4000, + CONF_MAX_KELVIN: 5000, + CONF_TUYA_MAX_COLTEMP: 12000, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Test common options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DISCOVERY_INTERVAL: 100, + CONF_QUERY_INTERVAL: 50, + CONF_QUERY_DEVICE: LIGHT_ID, + }, + ) + + # Verify results + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + climate_options = config_entry.options[CLIMATE_ID] + assert climate_options[CONF_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert climate_options[CONF_TEMP_DIVIDER] == 10 + assert climate_options[CONF_CURR_TEMP_DIVIDER] == 5 + assert climate_options[CONF_SET_TEMP_DIVIDED] is False + assert climate_options[CONF_TEMP_STEP_OVERRIDE] == STEP_HALVES + assert climate_options[CONF_MIN_TEMP] == 12 + assert climate_options[CONF_MAX_TEMP] == 22 + + light_options = config_entry.options[LIGHT_ID] + assert light_options[CONF_SUPPORT_COLOR] is True + assert light_options[CONF_BRIGHTNESS_RANGE_MODE] == 1 + assert light_options[CONF_MIN_KELVIN] == 4000 + assert light_options[CONF_MAX_KELVIN] == 5000 + assert light_options[CONF_TUYA_MAX_COLTEMP] == 12000 + + assert config_entry.options[CONF_DISCOVERY_INTERVAL] == 100 + assert config_entry.options[CONF_QUERY_INTERVAL] == 50 + assert config_entry.options[CONF_QUERY_DEVICE] == LIGHT_ID diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py index ee7f072a65c..580e5f83ebf 100644 --- a/tests/components/twilio/test_init.py +++ b/tests/components/twilio/test_init.py @@ -1,17 +1,19 @@ """Test the init file of Twilio.""" -from unittest.mock import patch - from homeassistant import data_entry_flow from homeassistant.components import twilio +from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback async def test_config_flow_registers_webhook(hass, aiohttp_client): """Test setting up Twilio and sending webhook.""" - with patch("homeassistant.util.get_local_ip", return_value="example.com"): - result = await hass.config_entries.flow.async_init( - "twilio", context={"source": "user"} - ) + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + result = await hass.config_entries.flow.async_init( + "twilio", context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM, result result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 88c1cbe586d..a28f5f5f7c5 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -97,7 +97,7 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): CONF_HOST: "unifi", CONF_USERNAME: "", CONF_PASSWORD: "", - CONF_PORT: 8443, + CONF_PORT: 443, CONF_VERIFY_SSL: False, } @@ -112,7 +112,9 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): aioclient_mock.get( "https://1.2.3.4:1234/api/self/sites", json={ - "data": [{"desc": "Site name", "name": "site_id", "role": "admin"}], + "data": [ + {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} + ], "meta": {"rc": "ok"}, }, headers={"content-type": CONTENT_TYPE_JSON}, @@ -132,6 +134,12 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Site name" assert result["data"] == { + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 1234, + CONF_SITE_ID: "site_id", + CONF_VERIFY_SSL: True, CONF_CONTROLLER: { CONF_HOST: "1.2.3.4", CONF_USERNAME: "username", @@ -139,11 +147,11 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): CONF_PORT: 1234, CONF_SITE_ID: "site_id", CONF_VERIFY_SSL: True, - } + }, } -async def test_flow_works_multiple_sites(hass, aioclient_mock): +async def test_flow_multiple_sites(hass, aioclient_mock): """Test config flow works when finding multiple sites.""" result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": "user"} @@ -164,8 +172,8 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock): "https://1.2.3.4:1234/api/self/sites", json={ "data": [ - {"name": "default", "role": "admin", "desc": "site name"}, - {"name": "site2", "role": "admin", "desc": "site2 name"}, + {"name": "default", "role": "admin", "desc": "site name", "_id": "1"}, + {"name": "site2", "role": "admin", "desc": "site2 name", "_id": "2"}, ], "meta": {"rc": "ok"}, }, @@ -185,14 +193,66 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "site" - assert result["data_schema"]({"site": "default"}) - assert result["data_schema"]({"site": "site2"}) + assert result["data_schema"]({"site": "1"}) + assert result["data_schema"]({"site": "2"}) -async def test_flow_fails_site_already_configured(hass, aioclient_mock): - """Test config flow.""" +async def test_flow_raise_already_configured(hass, aioclient_mock): + """Test config flow aborts since a connected config entry already exists.""" + await setup_unifi_integration(hass, aioclient_mock) + + result = await hass.config_entries.flow.async_init( + UNIFI_DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + aioclient_mock.clear_requests() + + aioclient_mock.get("https://1.2.3.4:1234", status=302) + + aioclient_mock.post( + "https://1.2.3.4:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + "https://1.2.3.4:1234/api/self/sites", + json={ + "data": [ + {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} + ], + "meta": {"rc": "ok"}, + }, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_aborts_configuration_updated(hass, aioclient_mock): + """Test config flow aborts since a connected config entry already exists.""" entry = MockConfigEntry( - domain=UNIFI_DOMAIN, data={"controller": {"host": "1.2.3.4", "site": "site_id"}} + domain=UNIFI_DOMAIN, data={"host": "1.2.3.4", "site": "office"}, unique_id="2" + ) + entry.add_to_hass(hass) + + entry = MockConfigEntry( + domain=UNIFI_DOMAIN, data={"host": "1.2.3.4", "site": "site_id"}, unique_id="1" ) entry.add_to_hass(hass) @@ -214,25 +274,28 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock): aioclient_mock.get( "https://1.2.3.4:1234/api/self/sites", json={ - "data": [{"desc": "Site name", "name": "site_id", "role": "admin"}], + "data": [ + {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} + ], "meta": {"rc": "ok"}, }, headers={"content-type": CONTENT_TYPE_JSON}, ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_PORT: 1234, - CONF_VERIFY_SSL: True, - }, - ) + with patch("homeassistant.components.unifi.async_setup_entry"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "configuration_updated" async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock): @@ -289,45 +352,23 @@ async def test_flow_fails_controller_unavailable(hass, aioclient_mock): assert result["errors"] == {"base": "service_unavailable"} -async def test_flow_fails_unknown_problem(hass, aioclient_mock): - """Test config flow.""" - result = await hass.config_entries.flow.async_init( - UNIFI_DOMAIN, context={"source": "user"} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - with patch("aiounifi.Controller.login", side_effect=Exception): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_PORT: 1234, - CONF_VERIFY_SSL: True, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - - async def test_reauth_flow_update_configuration(hass, aioclient_mock): """Verify reauth flow can update controller configuration.""" - controller = await setup_unifi_integration(hass) + config_entry = await setup_unifi_integration(hass, aioclient_mock) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + controller.available = False result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": SOURCE_REAUTH}, - data=controller.config_entry, + data=config_entry, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER + aioclient_mock.clear_requests() + aioclient_mock.get("https://1.2.3.4:1234", status=302) aioclient_mock.post( @@ -339,7 +380,9 @@ async def test_reauth_flow_update_configuration(hass, aioclient_mock): aioclient_mock.get( "https://1.2.3.4:1234/api/self/sites", json={ - "data": [{"desc": "Site name", "name": "site_id", "role": "admin"}], + "data": [ + {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} + ], "meta": {"rc": "ok"}, }, headers={"content-type": CONTENT_TYPE_JSON}, @@ -358,15 +401,16 @@ async def test_reauth_flow_update_configuration(hass, aioclient_mock): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "reauth_successful" - assert controller.host == "1.2.3.4" - assert controller.config_entry.data[CONF_CONTROLLER][CONF_USERNAME] == "new_name" - assert controller.config_entry.data[CONF_CONTROLLER][CONF_PASSWORD] == "new_pass" + assert config_entry.data[CONF_HOST] == "1.2.3.4" + assert config_entry.data[CONF_USERNAME] == "new_name" + assert config_entry.data[CONF_PASSWORD] == "new_pass" -async def test_advanced_option_flow(hass): +async def test_advanced_option_flow(hass, aioclient_mock): """Test advanced config flow options.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=CLIENTS, devices_response=DEVICES, wlans_response=WLANS, @@ -375,7 +419,7 @@ async def test_advanced_option_flow(hass): ) result = await hass.config_entries.options.async_init( - controller.config_entry.entry_id, context={"show_advanced_options": True} + config_entry.entry_id, context={"show_advanced_options": True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -434,10 +478,11 @@ async def test_advanced_option_flow(hass): } -async def test_simple_option_flow(hass): +async def test_simple_option_flow(hass, aioclient_mock): """Test simple config flow options.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=CLIENTS, wlans_response=WLANS, dpigroup_response=DPI_GROUPS, @@ -445,7 +490,7 @@ async def test_simple_option_flow(hass): ) result = await hass.config_entries.options.async_init( - controller.config_entry.entry_id, context={"show_advanced_options": False} + config_entry.entry_id, context={"show_advanced_options": False} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -501,7 +546,7 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass): await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( domain=UNIFI_DOMAIN, - data={"controller": {"host": "192.168.208.1", "site": "site_id"}}, + data={"host": "192.168.208.1", "site": "site_id"}, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 6acd507eaad..00865b4e910 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -1,5 +1,5 @@ """Test UniFi Controller.""" -from collections import deque + from copy import deepcopy from datetime import timedelta from unittest.mock import patch @@ -33,14 +33,18 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, ) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +DEFAULT_HOST = "1.2.3.4" +DEFAULT_SITE = "site_id" + CONTROLLER_HOST = { "hostname": "controller_host", - "ip": "1.2.3.4", + "ip": DEFAULT_HOST, "is_wired": True, "last_seen": 1562600145, "mac": "10:00:00:00:00:01", @@ -54,35 +58,103 @@ CONTROLLER_HOST = { } CONTROLLER_DATA = { - CONF_HOST: "1.2.3.4", + CONF_HOST: DEFAULT_HOST, CONF_USERNAME: "username", CONF_PASSWORD: "password", CONF_PORT: 1234, - CONF_SITE_ID: "site_id", + CONF_SITE_ID: DEFAULT_SITE, CONF_VERIFY_SSL: False, } -ENTRY_CONFIG = {CONF_CONTROLLER: CONTROLLER_DATA} +ENTRY_CONFIG = {**CONTROLLER_DATA, CONF_CONTROLLER: CONTROLLER_DATA} ENTRY_OPTIONS = {} CONFIGURATION = [] -SITES = {"Site name": {"desc": "Site name", "name": "site_id", "role": "admin"}} +SITE = [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] DESCRIPTION = [{"name": "username", "site_name": "site_id", "site_role": "admin"}] +def mock_default_unifi_requests( + aioclient_mock, + host, + site_id, + sites=None, + description=None, + clients_response=None, + clients_all_response=None, + devices_response=None, + dpiapp_response=None, + dpigroup_response=None, + wlans_response=None, +): + """Mock default UniFi requests responses.""" + aioclient_mock.get(f"https://{host}:1234", status=302) # Check UniFi OS + + aioclient_mock.post( + f"https://{host}:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"https://{host}:1234/api/self/sites", + json={"data": sites or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/self", + json={"data": description or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/stat/sta", + json={"data": clients_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/user", + json={"data": clients_all_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/stat/device", + json={"data": devices_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/dpiapp", + json={"data": dpiapp_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/dpigroup", + json={"data": dpigroup_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/wlanconf", + json={"data": wlans_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + async def setup_unifi_integration( hass, + aioclient_mock=None, + *, config=ENTRY_CONFIG, options=ENTRY_OPTIONS, - sites=SITES, + sites=SITE, site_description=DESCRIPTION, clients_response=None, - devices_response=None, clients_all_response=None, - wlans_response=None, - dpigroup_response=None, + devices_response=None, dpiapp_response=None, + dpigroup_response=None, + wlans_response=None, known_wireless_clients=None, controllers=None, ): @@ -94,6 +166,8 @@ async def setup_unifi_integration( data=deepcopy(config), options=deepcopy(options), entry_id=1, + unique_id="1", + version=1, ) config_entry.add_to_hass(hass) @@ -102,82 +176,39 @@ async def setup_unifi_integration( known_wireless_clients, config_entry ) - mock_client_responses = deque() - if clients_response: - mock_client_responses.append(clients_response) + if aioclient_mock: + mock_default_unifi_requests( + aioclient_mock, + host=config_entry.data[CONF_HOST], + site_id=config_entry.data[CONF_SITE_ID], + sites=sites, + description=site_description, + clients_response=clients_response, + clients_all_response=clients_all_response, + devices_response=devices_response, + dpiapp_response=dpiapp_response, + dpigroup_response=dpigroup_response, + wlans_response=wlans_response, + ) - mock_device_responses = deque() - if devices_response: - mock_device_responses.append(devices_response) - - mock_client_all_responses = deque() - if clients_all_response: - mock_client_all_responses.append(clients_all_response) - - mock_wlans_responses = deque() - if wlans_response: - mock_wlans_responses.append(wlans_response) - - mock_dpigroup_responses = deque() - if dpigroup_response: - mock_dpigroup_responses.append(dpigroup_response) - - mock_dpiapp_responses = deque() - if dpiapp_response: - mock_dpiapp_responses.append(dpiapp_response) - - mock_requests = [] - - async def mock_request(self, method, path, json=None): - mock_requests.append({"method": method, "path": path, "json": json}) - - if path == "/stat/sta" and mock_client_responses: - return mock_client_responses.popleft() - if path == "/stat/device" and mock_device_responses: - return mock_device_responses.popleft() - if path == "/rest/user" and mock_client_all_responses: - return mock_client_all_responses.popleft() - if path == "/rest/wlanconf" and mock_wlans_responses: - return mock_wlans_responses.popleft() - if path == "/rest/dpigroup" and mock_dpigroup_responses: - return mock_dpigroup_responses.popleft() - if path == "/rest/dpiapp" and mock_dpiapp_responses: - return mock_dpiapp_responses.popleft() - return {} - - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", - return_value=True, - ), patch("aiounifi.Controller.sites", return_value=sites), patch( - "aiounifi.Controller.site_description", return_value=site_description - ), patch( - "aiounifi.Controller.request", new=mock_request - ), patch.object( - aiounifi.websocket.WSClient, "start", return_value=True - ): + with patch.object(aiounifi.websocket.WSClient, "start", return_value=True): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() if config_entry.entry_id not in hass.data[UNIFI_DOMAIN]: return None - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.mock_client_responses = mock_client_responses - controller.mock_device_responses = mock_device_responses - controller.mock_client_all_responses = mock_client_all_responses - controller.mock_wlans_responses = mock_wlans_responses - controller.mock_requests = mock_requests - - return controller + return config_entry -async def test_controller_setup(hass): +async def test_controller_setup(hass, aioclient_mock): """Successful setup.""" with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", return_value=True, ) as forward_entry_setup: - controller = await setup_unifi_integration(hass) + config_entry = await setup_unifi_integration(hass, aioclient_mock) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] entry = controller.config_entry assert len(forward_entry_setup.mock_calls) == len(SUPPORTED_PLATFORMS) @@ -187,8 +218,8 @@ async def test_controller_setup(hass): assert controller.host == CONTROLLER_DATA[CONF_HOST] assert controller.site == CONTROLLER_DATA[CONF_SITE_ID] - assert controller.site_name in SITES - assert controller.site_role == SITES[controller.site_name]["role"] + assert controller.site_name == SITE[0]["desc"] + assert controller.site_role == SITE[0]["role"] assert controller.option_allow_bandwidth_sensors == DEFAULT_ALLOW_BANDWIDTH_SENSORS assert controller.option_allow_uptime_sensors == DEFAULT_ALLOW_UPTIME_SENSORS @@ -201,14 +232,19 @@ async def test_controller_setup(hass): assert controller.mac is None - assert controller.signal_update == "unifi-update-1.2.3.4-site_id" - assert controller.signal_remove == "unifi-remove-1.2.3.4-site_id" - assert controller.signal_options_update == "unifi-options-1.2.3.4-site_id" + assert controller.signal_reachable == "unifi-reachable-1" + assert controller.signal_update == "unifi-update-1" + assert controller.signal_remove == "unifi-remove-1" + assert controller.signal_options_update == "unifi-options-1" + assert controller.signal_heartbeat_missed == "unifi-heartbeat-missed" -async def test_controller_mac(hass): +async def test_controller_mac(hass, aioclient_mock): """Test that it is possible to identify controller mac.""" - controller = await setup_unifi_integration(hass, clients_response=[CONTROLLER_HOST]) + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_response=[CONTROLLER_HOST] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert controller.mac == CONTROLLER_HOST["mac"] @@ -243,9 +279,10 @@ async def test_controller_unknown_error(hass): assert hass.data[UNIFI_DOMAIN] == {} -async def test_reset_after_successful_setup(hass): +async def test_reset_after_successful_setup(hass, aioclient_mock): """Calling reset when the entry has been setup.""" - controller = await setup_unifi_integration(hass) + config_entry = await setup_unifi_integration(hass, aioclient_mock) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(controller.listeners) == 6 @@ -256,9 +293,12 @@ async def test_reset_after_successful_setup(hass): assert len(controller.listeners) == 0 -async def test_wireless_client_event_calls_update_wireless_devices(hass): +async def test_wireless_client_event_calls_update_wireless_devices( + hass, aioclient_mock +): """Call update_wireless_devices method when receiving wireless client event.""" - controller = await setup_unifi_integration(hass) + config_entry = await setup_unifi_integration(hass, aioclient_mock) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] with patch( "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 6462fcba943..e8081a831c2 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -25,7 +25,6 @@ from homeassistant.components.unifi.const import ( ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import entity_registry -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .test_controller import ENTRY_CONFIG, setup_unifi_integration @@ -151,27 +150,19 @@ EVENT_DEVICE_2_UPGRADED = { } -async def test_platform_manually_configured(hass): - """Test that nothing happens when configuring unifi through device tracker platform.""" - assert ( - await async_setup_component( - hass, TRACKER_DOMAIN, {TRACKER_DOMAIN: {"platform": UNIFI_DOMAIN}} - ) - is False - ) - assert UNIFI_DOMAIN not in hass.data - - -async def test_no_clients(hass): +async def test_no_clients(hass, aioclient_mock): """Test the update_clients function when no clients are found.""" - await setup_unifi_integration(hass) + await setup_unifi_integration(hass, aioclient_mock) assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 -async def test_tracked_wireless_clients(hass): +async def test_tracked_wireless_clients(hass, aioclient_mock): """Test the update_items function with some clients.""" - controller = await setup_unifi_integration(hass, clients_response=[CLIENT_1]) + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_response=[CLIENT_1] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 client_1 = hass.states.get("device_tracker.client_1") @@ -224,17 +215,19 @@ async def test_tracked_wireless_clients(hass): assert client_1.state == "home" -async def test_tracked_clients(hass): +async def test_tracked_clients(hass, aioclient_mock): """Test the update_items function with some clients.""" client_4_copy = copy(CLIENT_4) client_4_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_SSID_FILTER: ["ssid"]}, clients_response=[CLIENT_1, CLIENT_2, CLIENT_3, CLIENT_5, client_4_copy], known_wireless_clients=(CLIENT_4["mac"],), ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 4 client_1 = hass.states.get("device_tracker.client_1") @@ -269,12 +262,14 @@ async def test_tracked_clients(hass): assert client_1.state == "home" -async def test_tracked_devices(hass): +async def test_tracked_devices(hass, aioclient_mock): """Test the update_items function with some devices.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, devices_response=[DEVICE_1, DEVICE_2], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 device_1 = hass.states.get("device_tracker.device_1") @@ -338,11 +333,12 @@ async def test_tracked_devices(hass): assert device.sw_version == EVENT_DEVICE_2_UPGRADED["version_to"] -async def test_remove_clients(hass): +async def test_remove_clients(hass, aioclient_mock): """Test the remove_items function with some clients.""" - controller = await setup_unifi_integration( - hass, clients_response=[CLIENT_1, CLIENT_2] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_response=[CLIENT_1, CLIENT_2] ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 client_1 = hass.states.get("device_tracker.client_1") @@ -357,6 +353,7 @@ async def test_remove_clients(hass): } controller.api.session_handler(SIGNAL_DATA) await hass.async_block_till_done() + await hass.async_block_till_done() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 @@ -367,13 +364,15 @@ async def test_remove_clients(hass): assert wired_client is not None -async def test_controller_state_change(hass): +async def test_controller_state_change(hass, aioclient_mock): """Verify entities state reflect on controller becoming unavailable.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 client_1 = hass.states.get("device_tracker.client_1") @@ -405,10 +404,11 @@ async def test_controller_state_change(hass): assert device_1.state == "home" -async def test_option_track_clients(hass): +async def test_option_track_clients(hass, aioclient_mock): """Test the tracking of clients can be turned off.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) @@ -424,7 +424,7 @@ async def test_option_track_clients(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_CLIENTS: False}, ) await hass.async_block_till_done() @@ -439,7 +439,7 @@ async def test_option_track_clients(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_CLIENTS: True}, ) await hass.async_block_till_done() @@ -454,10 +454,11 @@ async def test_option_track_clients(hass): assert device_1 is not None -async def test_option_track_wired_clients(hass): +async def test_option_track_wired_clients(hass, aioclient_mock): """Test the tracking of wired clients can be turned off.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) @@ -473,7 +474,7 @@ async def test_option_track_wired_clients(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_WIRED_CLIENTS: False}, ) await hass.async_block_till_done() @@ -488,7 +489,7 @@ async def test_option_track_wired_clients(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_WIRED_CLIENTS: True}, ) await hass.async_block_till_done() @@ -503,10 +504,11 @@ async def test_option_track_wired_clients(hass): assert device_1 is not None -async def test_option_track_devices(hass): +async def test_option_track_devices(hass, aioclient_mock): """Test the tracking of devices can be turned off.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) @@ -522,7 +524,7 @@ async def test_option_track_devices(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_DEVICES: False}, ) await hass.async_block_till_done() @@ -537,7 +539,7 @@ async def test_option_track_devices(hass): assert device_1 is None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_DEVICES: True}, ) await hass.async_block_till_done() @@ -552,7 +554,7 @@ async def test_option_track_devices(hass): assert device_1 is not None -async def test_option_ssid_filter(hass): +async def test_option_ssid_filter(hass, aioclient_mock): """Test the SSID filter works. Client 1 will travel from a supported SSID to an unsupported ssid. @@ -561,9 +563,10 @@ async def test_option_ssid_filter(hass): client_1_copy = copy(CLIENT_1) client_1_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - controller = await setup_unifi_integration( - hass, clients_response=[client_1_copy, CLIENT_3] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_response=[client_1_copy, CLIENT_3] ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 client_1 = hass.states.get("device_tracker.client_1") @@ -574,7 +577,7 @@ async def test_option_ssid_filter(hass): # Setting SSID filter will remove clients outside of filter hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_SSID_FILTER: ["ssid"]}, ) await hass.async_block_till_done() @@ -609,7 +612,7 @@ async def test_option_ssid_filter(hass): # Remove SSID filter hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_SSID_FILTER: []}, ) await hass.async_block_till_done() @@ -656,7 +659,7 @@ async def test_option_ssid_filter(hass): assert client_3.state == "not_home" -async def test_wireless_client_go_wired_issue(hass): +async def test_wireless_client_go_wired_issue(hass, aioclient_mock): """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. @@ -664,7 +667,10 @@ async def test_wireless_client_go_wired_issue(hass): client_1_client = copy(CLIENT_1) client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - controller = await setup_unifi_integration(hass, clients_response=[client_1_client]) + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_response=[client_1_client] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 # Client is wireless @@ -717,14 +723,18 @@ async def test_wireless_client_go_wired_issue(hass): assert client_1.attributes["is_wired"] is False -async def test_option_ignore_wired_bug(hass): +async def test_option_ignore_wired_bug(hass, aioclient_mock): """Test option to ignore wired bug.""" client_1_client = copy(CLIENT_1) client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - controller = await setup_unifi_integration( - hass, options={CONF_IGNORE_WIRED_BUG: True}, clients_response=[client_1_client] + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options={CONF_IGNORE_WIRED_BUG: True}, + clients_response=[client_1_client], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 # Client is wireless @@ -777,7 +787,7 @@ async def test_option_ignore_wired_bug(hass): assert client_1.attributes["is_wired"] is False -async def test_restoring_client(hass): +async def test_restoring_client(hass, aioclient_mock): """Test the update_items function with some clients.""" config_entry = config_entries.ConfigEntry( version=1, @@ -809,6 +819,7 @@ async def test_restoring_client(hass): await setup_unifi_integration( hass, + aioclient_mock, options={CONF_BLOCK_CLIENT: True}, clients_response=[CLIENT_2], clients_all_response=[CLIENT_1], @@ -819,10 +830,11 @@ async def test_restoring_client(hass): assert device_1 is not None -async def test_dont_track_clients(hass): +async def test_dont_track_clients(hass, aioclient_mock): """Test don't track clients config works.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_CLIENTS: False}, clients_response=[CLIENT_1], devices_response=[DEVICE_1], @@ -836,7 +848,7 @@ async def test_dont_track_clients(hass): assert device_1 is not None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_CLIENTS: True}, ) await hass.async_block_till_done() @@ -850,10 +862,11 @@ async def test_dont_track_clients(hass): assert device_1 is not None -async def test_dont_track_devices(hass): +async def test_dont_track_devices(hass, aioclient_mock): """Test don't track devices config works.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_DEVICES: False}, clients_response=[CLIENT_1], devices_response=[DEVICE_1], @@ -867,7 +880,7 @@ async def test_dont_track_devices(hass): assert device_1 is None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_DEVICES: True}, ) await hass.async_block_till_done() @@ -881,10 +894,11 @@ async def test_dont_track_devices(hass): assert device_1 is not None -async def test_dont_track_wired_clients(hass): +async def test_dont_track_wired_clients(hass, aioclient_mock): """Test don't track wired clients config works.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_WIRED_CLIENTS: False}, clients_response=[CLIENT_1, CLIENT_2], ) @@ -897,7 +911,7 @@ async def test_dont_track_wired_clients(hass): assert client_2 is None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_TRACK_WIRED_CLIENTS: True}, ) await hass.async_block_till_done() diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cc2a4b3e4a3..6d8b894fc34 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -2,23 +2,24 @@ from unittest.mock import AsyncMock, Mock, patch from homeassistant.components import unifi -from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi import async_flatten_entry_data +from homeassistant.components.unifi.const import CONF_CONTROLLER, DOMAIN as UNIFI_DOMAIN from homeassistant.setup import async_setup_component -from .test_controller import setup_unifi_integration +from .test_controller import CONTROLLER_DATA, ENTRY_CONFIG, setup_unifi_integration from tests.common import MockConfigEntry, mock_coro async def test_setup_with_no_config(hass): - """Test that we do not discover anything or try to set up a bridge.""" + """Test that we do not discover anything or try to set up a controller.""" assert await async_setup_component(hass, UNIFI_DOMAIN, {}) is True assert UNIFI_DOMAIN not in hass.data -async def test_successful_config_entry(hass): +async def test_successful_config_entry(hass, aioclient_mock): """Test that configured options for a host are loaded via config entry.""" - await setup_unifi_integration(hass) + await setup_unifi_integration(hass, aioclient_mock) assert hass.data[UNIFI_DOMAIN] @@ -35,17 +36,9 @@ async def test_controller_no_mac(hass): """Test that configured options for a host are loaded via config entry.""" entry = MockConfigEntry( domain=UNIFI_DOMAIN, - data={ - "controller": { - "host": "0.0.0.0", - "username": "user", - "password": "pass", - "port": 80, - "site": "default", - "verify_ssl": True, - }, - "poe_control": True, - }, + data=ENTRY_CONFIG, + unique_id="1", + version=1, ) entry.add_to_hass(hass) mock_registry = Mock() @@ -64,10 +57,21 @@ async def test_controller_no_mac(hass): assert len(mock_registry.mock_calls) == 0 -async def test_unload_entry(hass): +async def test_flatten_entry_data(hass): + """Verify entry data can be flattened.""" + entry = MockConfigEntry( + domain=UNIFI_DOMAIN, + data={CONF_CONTROLLER: CONTROLLER_DATA}, + ) + await async_flatten_entry_data(hass, entry) + + assert entry.data == ENTRY_CONFIG + + +async def test_unload_entry(hass, aioclient_mock): """Test being able to unload an entry.""" - controller = await setup_unifi_integration(hass) + config_entry = await setup_unifi_integration(hass, aioclient_mock) assert hass.data[UNIFI_DOMAIN] - assert await unifi.async_unload_entry(hass, controller.config_entry) + assert await unifi.async_unload_entry(hass, config_entry) assert not hass.data[UNIFI_DOMAIN] diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index dc2fea634c9..c668bf3789f 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.unifi.const import ( DOMAIN as UNIFI_DOMAIN, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.setup import async_setup_component from .test_controller import setup_unifi_integration @@ -50,35 +49,25 @@ CLIENTS = [ ] -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a controller.""" - assert ( - await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": UNIFI_DOMAIN}} - ) - is True - ) - assert UNIFI_DOMAIN not in hass.data - - -async def test_no_clients(hass): +async def test_no_clients(hass, aioclient_mock): """Test the update_clients function when no clients are found.""" - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True, }, ) - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 -async def test_sensors(hass): +async def test_sensors(hass, aioclient_mock): """Test the update_items function with some clients.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True, @@ -87,8 +76,8 @@ async def test_sensors(hass): }, clients_response=CLIENTS, ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 wired_client_rx = hass.states.get("sensor.wired_client_name_rx") @@ -129,7 +118,7 @@ async def test_sensors(hass): assert wireless_client_uptime.state == "2020-09-15T14:41:00+00:00" hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={ CONF_ALLOW_BANDWIDTH_SENSORS: False, CONF_ALLOW_UPTIME_SENSORS: False, @@ -150,7 +139,7 @@ async def test_sensors(hass): assert wireless_client_uptime is None hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={ CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True, @@ -189,16 +178,18 @@ async def test_sensors(hass): assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 -async def test_remove_sensors(hass): +async def test_remove_sensors(hass, aioclient_mock): """Test the remove_items function with some clients.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True, }, clients_response=CLIENTS, ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 903db479d34..e5a3a7eccc4 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -17,7 +17,6 @@ from homeassistant.components.unifi.const import ( ) from homeassistant.components.unifi.switch import POE_SWITCH from homeassistant.helpers import entity_registry -from homeassistant.setup import async_setup_component from .test_controller import ( CONTROLLER_HOST, @@ -282,21 +281,11 @@ DPI_APPS = [ ] -async def test_platform_manually_configured(hass): - """Test that we do not discover anything or try to set up a controller.""" - assert ( - await async_setup_component( - hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": UNIFI_DOMAIN}} - ) - is True - ) - assert UNIFI_DOMAIN not in hass.data - - -async def test_no_clients(hass): +async def test_no_clients(hass, aioclient_mock): """Test the update_clients function when no clients are found.""" - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False, @@ -304,45 +293,46 @@ async def test_no_clients(hass): }, ) - assert len(controller.mock_requests) == 6 + assert aioclient_mock.call_count == 10 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 -async def test_controller_not_client(hass): +async def test_controller_not_client(hass, aioclient_mock): """Test that the controller doesn't become a switch.""" - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, clients_response=[CONTROLLER_HOST], devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 cloudkey = hass.states.get("switch.cloud_key") assert cloudkey is None -async def test_not_admin(hass): +async def test_not_admin(hass, aioclient_mock): """Test that switch platform only work on an admin account.""" description = deepcopy(DESCRIPTION) description[0]["site_role"] = "not admin" - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, site_description=description, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 -async def test_switches(hass): +async def test_switches(hass, aioclient_mock): """Test the update_items function with some clients.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], CONF_TRACK_CLIENTS: False, @@ -354,8 +344,8 @@ async def test_switches(hass): dpigroup_response=DPI_GROUPS, dpiapp_response=DPI_APPS, ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 4 switch_1 = hass.states.get("switch.poe_client_1") @@ -381,38 +371,44 @@ async def test_switches(hass): assert dpi_switch is not None assert dpi_switch.state == "on" + # Block and unblock client + + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 7 - assert controller.mock_requests[6] == { - "json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"}, - "method": "post", - "path": "/cmd/stamgr", + assert aioclient_mock.call_count == 11 + assert aioclient_mock.mock_calls[10][2] == { + "mac": "00:00:00:00:01:01", + "cmd": "block-sta", } await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 8 - assert controller.mock_requests[7] == { - "json": {"mac": "00:00:00:00:01:01", "cmd": "unblock-sta"}, - "method": "post", - "path": "/cmd/stamgr", + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { + "mac": "00:00:00:00:01:01", + "cmd": "unblock-sta", } + # Enable and disable DPI + + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/dpiapp/5f976f62e3c58f018ec7e17d", + ) + await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert len(controller.mock_requests) == 9 - assert controller.mock_requests[8] == { - "json": {"enabled": False}, - "method": "put", - "path": "/rest/dpiapp/5f976f62e3c58f018ec7e17d", - } + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -420,22 +416,20 @@ async def test_switches(hass): {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert len(controller.mock_requests) == 10 - assert controller.mock_requests[9] == { - "json": {"enabled": True}, - "method": "put", - "path": "/rest/dpiapp/5f976f62e3c58f018ec7e17d", - } + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == {"enabled": True} -async def test_remove_switches(hass): +async def test_remove_switches(hass, aioclient_mock): """Test the update_items function with some clients.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, clients_response=[CLIENT_1, UNBLOCKED], devices_response=[DEVICE_1], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 poe_switch = hass.states.get("switch.poe_client_1") @@ -460,10 +454,11 @@ async def test_remove_switches(hass): assert block_switch is None -async def test_block_switches(hass): +async def test_block_switches(hass, aioclient_mock): """Test the update_items function with some clients.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], CONF_TRACK_CLIENTS: False, @@ -472,6 +467,7 @@ async def test_block_switches(hass): clients_response=[UNBLOCKED], clients_all_response=[BLOCKED], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -507,31 +503,34 @@ async def test_block_switches(hass): assert blocked is not None assert blocked.state == "off" + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 7 - assert controller.mock_requests[6] == { - "json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"}, - "method": "post", - "path": "/cmd/stamgr", + assert aioclient_mock.call_count == 11 + assert aioclient_mock.mock_calls[10][2] == { + "mac": "00:00:00:00:01:01", + "cmd": "block-sta", } await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 8 - assert controller.mock_requests[7] == { - "json": {"mac": "00:00:00:00:01:01", "cmd": "unblock-sta"}, - "method": "post", - "path": "/cmd/stamgr", + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { + "mac": "00:00:00:00:01:01", + "cmd": "unblock-sta", } -async def test_new_client_discovered_on_block_control(hass): +async def test_new_client_discovered_on_block_control(hass, aioclient_mock): """Test if 2nd update has a new client.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_BLOCK_CLIENT: [BLOCKED["mac"]], CONF_TRACK_CLIENTS: False, @@ -539,8 +538,8 @@ async def test_new_client_discovered_on_block_control(hass): CONF_DPI_RESTRICTIONS: False, }, ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 blocked = hass.states.get("switch.block_client_1") @@ -567,10 +566,11 @@ async def test_new_client_discovered_on_block_control(hass): assert blocked is not None -async def test_option_block_clients(hass): +async def test_option_block_clients(hass, aioclient_mock): """Test the changes to option reflects accordingly.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, clients_all_response=[BLOCKED, UNBLOCKED], ) @@ -578,7 +578,7 @@ async def test_option_block_clients(hass): # Add a second switch hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]]}, ) await hass.async_block_till_done() @@ -586,7 +586,7 @@ async def test_option_block_clients(hass): # Remove the second switch again hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, ) await hass.async_block_till_done() @@ -594,7 +594,7 @@ async def test_option_block_clients(hass): # Enable one and remove another one hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, ) await hass.async_block_till_done() @@ -602,17 +602,18 @@ async def test_option_block_clients(hass): # Remove one hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_BLOCK_CLIENT: []}, ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 -async def test_option_remove_switches(hass): +async def test_option_remove_switches(hass, aioclient_mock): """Test removal of DPI switch when options updated.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False, @@ -626,23 +627,24 @@ async def test_option_remove_switches(hass): # Disable DPI Switches hass.config_entries.async_update_entry( - controller.config_entry, + config_entry, options={CONF_DPI_RESTRICTIONS: False, CONF_POE_CLIENTS: False}, ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 -async def test_new_client_discovered_on_poe_control(hass): +async def test_new_client_discovered_on_poe_control(hass, aioclient_mock): """Test if 2nd update has a new client.""" - controller = await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, + aioclient_mock, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 controller.api.websocket._data = { @@ -665,47 +667,41 @@ async def test_new_client_discovered_on_poe_control(hass): switch_2 = hass.states.get("switch.poe_client_2") assert switch_2 is not None + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/mock-id", + ) + await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.poe_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 7 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 - assert controller.mock_requests[6] == { - "json": { - "port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}] - }, - "method": "put", - "path": "/rest/device/mock-id", + assert aioclient_mock.call_count == 11 + assert aioclient_mock.mock_calls[10][2] == { + "port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}] } await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.poe_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 8 - assert controller.mock_requests[7] == { - "json": { - "port_overrides": [ - {"port_idx": 1, "portconf_id": "1a1", "poe_mode": "auto"} - ] - }, - "method": "put", - "path": "/rest/device/mock-id", + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { + "port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "auto"}] } -async def test_ignore_multiple_poe_clients_on_same_port(hass): +async def test_ignore_multiple_poe_clients_on_same_port(hass, aioclient_mock): """Ignore when there are multiple POE driven clients on same port. If there is a non-UniFi switch powered by POE, clients will be transparently marked as having POE as well. """ - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, clients_response=POE_SWITCH_CLIENTS, devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 switch_1 = hass.states.get("switch.poe_client_1") @@ -714,7 +710,7 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass): assert switch_2 is None -async def test_restoring_client(hass): +async def test_restoring_client(hass, aioclient_mock): """Test the update_items function with some clients.""" config_entry = config_entries.ConfigEntry( version=1, @@ -744,8 +740,9 @@ async def test_restoring_client(hass): config_entry=config_entry, ) - controller = await setup_unifi_integration( + await setup_unifi_integration( hass, + aioclient_mock, options={ CONF_BLOCK_CLIENT: ["random mac"], CONF_TRACK_CLIENTS: False, @@ -756,7 +753,6 @@ async def test_restoring_client(hass): clients_all_response=[CLIENT_1], ) - assert len(controller.mock_requests) == 6 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 device_1 = hass.states.get("switch.client_1") diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index fd75620f318..8d8bc80234e 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -51,6 +51,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): self._tracks = 12 self._media_image_url = None self._shuffle = False + self._sound_mode = None self.service_calls = { "turn_on": mock_service( @@ -71,6 +72,9 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): "media_pause": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PAUSE ), + "media_stop": mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_STOP + ), "media_previous_track": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PREVIOUS_TRACK ), @@ -92,12 +96,21 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): "media_play_pause": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY_PAUSE ), + "select_sound_mode": mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOUND_MODE + ), "select_source": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE ), + "toggle": mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_TOGGLE + ), "clear_playlist": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_CLEAR_PLAYLIST ), + "repeat_set": mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_REPEAT_SET + ), "shuffle_set": mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SHUFFLE_SET ), @@ -162,18 +175,30 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): """Mock pause.""" self._state = STATE_PAUSED + def select_sound_mode(self, sound_mode): + """Set the sound mode.""" + self._sound_mode = sound_mode + def select_source(self, source): """Set the input source.""" self._source = source + def async_toggle(self): + """Toggle the power on the media player.""" + self._state = STATE_OFF if self._state == STATE_ON else STATE_ON + def clear_playlist(self): """Clear players playlist.""" self._tracks = 0 def set_shuffle(self, shuffle): - """Clear players playlist.""" + """Enable/disable shuffle mode.""" self._shuffle = shuffle + def set_repeat(self, repeat): + """Enable/disable repeat mode.""" + self._repeat = repeat + class TestMediaPlayer(unittest.TestCase): """Test the media_player module.""" @@ -205,9 +230,18 @@ class TestMediaPlayer(unittest.TestCase): self.mock_source_id = f"{input_select.DOMAIN}.source" self.hass.states.set(self.mock_source_id, "dvd") + self.mock_sound_mode_list_id = f"{input_select.DOMAIN}.sound_mode_list" + self.hass.states.set(self.mock_sound_mode_list_id, ["music", "movie"]) + + self.mock_sound_mode_id = f"{input_select.DOMAIN}.sound_mode" + self.hass.states.set(self.mock_sound_mode_id, "music") + self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format("shuffle") self.hass.states.set(self.mock_shuffle_switch_id, STATE_OFF) + self.mock_repeat_switch_id = switch.ENTITY_ID_FORMAT.format("repeat") + self.hass.states.set(self.mock_repeat_switch_id, STATE_OFF) + self.config_children_only = { "name": "test", "platform": "universal", @@ -230,6 +264,9 @@ class TestMediaPlayer(unittest.TestCase): "source_list": self.mock_source_list_id, "state": self.mock_state_switch_id, "shuffle": self.mock_shuffle_switch_id, + "repeat": self.mock_repeat_switch_id, + "sound_mode_list": self.mock_sound_mode_list_id, + "sound_mode": self.mock_sound_mode_id, }, } self.addCleanup(self.tear_down_cleanup) @@ -507,6 +544,17 @@ class TestMediaPlayer(unittest.TestCase): asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() assert ump.is_volume_muted + def test_sound_mode_list_children_and_attr(self): + """Test sound mode list property w/ children and attrs.""" + config = validate_config(self.config_children_and_attr) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + + assert "['music', 'movie']" == ump.sound_mode_list + + self.hass.states.set(self.mock_sound_mode_list_id, ["music", "movie", "game"]) + assert "['music', 'movie', 'game']" == ump.sound_mode_list + def test_source_list_children_and_attr(self): """Test source list property w/ children and attrs.""" config = validate_config(self.config_children_and_attr) @@ -518,6 +566,17 @@ class TestMediaPlayer(unittest.TestCase): self.hass.states.set(self.mock_source_list_id, ["dvd", "htpc", "game"]) assert "['dvd', 'htpc', 'game']" == ump.source_list + def test_sound_mode_children_and_attr(self): + """Test sound modeproperty w/ children and attrs.""" + config = validate_config(self.config_children_and_attr) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + + assert "music" == ump.sound_mode + + self.hass.states.set(self.mock_sound_mode_id, "movie") + assert "movie" == ump.sound_mode + def test_source_children_and_attr(self): """Test source property w/ children and attrs.""" config = validate_config(self.config_children_and_attr) @@ -579,8 +638,17 @@ class TestMediaPlayer(unittest.TestCase): "volume_down": excmd, "volume_mute": excmd, "volume_set": excmd, + "select_sound_mode": excmd, "select_source": excmd, + "repeat_set": excmd, "shuffle_set": excmd, + "media_play": excmd, + "media_pause": excmd, + "media_stop": excmd, + "media_next_track": excmd, + "media_previous_track": excmd, + "toggle": excmd, + "clear_playlist": excmd, } config = validate_config(config) @@ -598,13 +666,41 @@ class TestMediaPlayer(unittest.TestCase): | universal.SUPPORT_TURN_OFF | universal.SUPPORT_VOLUME_STEP | universal.SUPPORT_VOLUME_MUTE + | universal.SUPPORT_SELECT_SOUND_MODE | universal.SUPPORT_SELECT_SOURCE + | universal.SUPPORT_REPEAT_SET | universal.SUPPORT_SHUFFLE_SET | universal.SUPPORT_VOLUME_SET + | universal.SUPPORT_PLAY + | universal.SUPPORT_PAUSE + | universal.SUPPORT_STOP + | universal.SUPPORT_NEXT_TRACK + | universal.SUPPORT_PREVIOUS_TRACK + | universal.SUPPORT_CLEAR_PLAYLIST ) assert check_flags == ump.supported_features + def test_supported_features_play_pause(self): + """Test supported media commands with play_pause function.""" + config = copy(self.config_children_and_attr) + excmd = {"service": "media_player.test", "data": {"entity_id": "test"}} + config["commands"] = {"media_play_pause": excmd} + config = validate_config(config) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + + self.mock_mp_1._state = STATE_PLAYING + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() + asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + + check_flags = universal.SUPPORT_PLAY | universal.SUPPORT_PAUSE + + assert check_flags == ump.supported_features + def test_service_call_no_active_child(self): """Test a service call to children with no active child.""" config = validate_config(self.config_children_and_attr) @@ -663,6 +759,11 @@ class TestMediaPlayer(unittest.TestCase): ).result() assert 1 == len(self.mock_mp_2.service_calls["media_pause"]) + asyncio.run_coroutine_threadsafe( + ump.async_media_stop(), self.hass.loop + ).result() + assert 1 == len(self.mock_mp_2.service_calls["media_stop"]) + asyncio.run_coroutine_threadsafe( ump.async_media_previous_track(), self.hass.loop ).result() @@ -696,6 +797,11 @@ class TestMediaPlayer(unittest.TestCase): ).result() assert 1 == len(self.mock_mp_2.service_calls["media_play_pause"]) + asyncio.run_coroutine_threadsafe( + ump.async_select_sound_mode("music"), self.hass.loop + ).result() + assert 1 == len(self.mock_mp_2.service_calls["select_sound_mode"]) + asyncio.run_coroutine_threadsafe( ump.async_select_source("dvd"), self.hass.loop ).result() @@ -706,11 +812,19 @@ class TestMediaPlayer(unittest.TestCase): ).result() assert 1 == len(self.mock_mp_2.service_calls["clear_playlist"]) + asyncio.run_coroutine_threadsafe( + ump.async_set_repeat(True), self.hass.loop + ).result() + assert 1 == len(self.mock_mp_2.service_calls["repeat_set"]) + asyncio.run_coroutine_threadsafe( ump.async_set_shuffle(True), self.hass.loop ).result() assert 1 == len(self.mock_mp_2.service_calls["shuffle_set"]) + asyncio.run_coroutine_threadsafe(ump.async_toggle(), self.hass.loop).result() + assert 1 == len(self.mock_mp_2.service_calls["toggle"]) + def test_service_call_to_command(self): """Test service call to command.""" config = copy(self.config_children_only) @@ -758,6 +872,25 @@ async def test_state_template(hass): assert hass.states.get("media_player.tv").state == STATE_OFF +async def test_device_class(hass): + """Test device_class property.""" + hass.states.async_set("sensor.test_sensor", "on") + + await async_setup_component( + hass, + "media_player", + { + "media_player": { + "platform": "universal", + "name": "tv", + "device_class": "tv", + } + }, + ) + await hass.async_block_till_done() + assert hass.states.get("media_player.tv").attributes["device_class"] == "tv" + + async def test_invalid_state_template(hass): """Test invalid state template sets state to None.""" hass.states.async_set("sensor.test_sensor", "on") @@ -887,6 +1020,9 @@ async def test_reload(hass): assert hass.states.get("media_player.tv") is None assert hass.states.get("media_player.master_bed_tv").state == "on" assert hass.states.get("media_player.master_bed_tv").attributes["source"] == "act2" + assert ( + "device_class" not in hass.states.get("media_player.master_bed_tv").attributes + ) def _get_fixtures_base_path(): diff --git a/tests/components/upnp/mock_device.py b/tests/components/upnp/mock_device.py index 17d9b5659c5..d6027608137 100644 --- a/tests/components/upnp/mock_device.py +++ b/tests/components/upnp/mock_device.py @@ -16,16 +16,14 @@ import homeassistant.util.dt as dt_util class MockDevice(Device): """Mock device for Device.""" - def __init__(self, udn): + def __init__(self, udn: str) -> None: """Initialize mock device.""" igd_device = object() super().__init__(igd_device) self._udn = udn - self.added_port_mappings = [] - self.removed_port_mappings = [] @classmethod - async def async_create_device(cls, hass, ssdp_location): + async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": """Return self.""" return cls("UDN") @@ -54,17 +52,10 @@ class MockDevice(Device): """Get the device type.""" return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" - async def _async_add_port_mapping( - self, external_port: int, local_ip: str, internal_port: int - ) -> None: - """Add a port mapping.""" - entry = [external_port, local_ip, internal_port] - self.added_port_mappings.append(entry) - - async def _async_delete_port_mapping(self, external_port: int) -> None: - """Remove a port mapping.""" - entry = external_port - self.removed_port_mappings.append(entry) + @property + def hostname(self) -> str: + """Get the hostname.""" + return "mock-hostname" async def async_get_traffic_data(self) -> Mapping[str, any]: """Get traffic data.""" diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index be7794ce8e9..77d04381a12 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -6,15 +6,20 @@ from unittest.mock import AsyncMock, patch from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( + CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, + DISCOVERY_HOSTNAME, DISCOVERY_LOCATION, + DISCOVERY_NAME, DISCOVERY_ST, DISCOVERY_UDN, + DISCOVERY_UNIQUE_ID, DISCOVERY_USN, DOMAIN, + DOMAIN_COORDINATORS, ) from homeassistant.components.upnp.device import Device from homeassistant.helpers.typing import HomeAssistantType @@ -28,25 +33,35 @@ from tests.common import MockConfigEntry async def test_flow_ssdp_discovery(hass: HomeAssistantType): """Test config flow: discovered + configured through ssdp.""" udn = "uuid:device_1" + location = "dummy" mock_device = MockDevice(udn) - discovery_infos = [ + discoveries = [ { + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] with patch.object( Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): + ), patch.object( + Device, "async_discover", AsyncMock(return_value=discoveries) + ), patch.object( + Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) + ): # Discovered via step ssdp. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data={ + ssdp.ATTR_SSDP_LOCATION: location, ssdp.ATTR_SSDP_ST: mock_device.device_type, + ssdp.ATTR_SSDP_USN: mock_device.usn, ssdp.ATTR_UPNP_UDN: mock_device.udn, - "friendlyName": mock_device.name, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -63,53 +78,103 @@ async def test_flow_ssdp_discovery(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, } -async def test_flow_ssdp_discovery_incomplete(hass: HomeAssistantType): +async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistantType): """Test config flow: incomplete discovery through ssdp.""" udn = "uuid:device_1" + location = "dummy" mock_device = MockDevice(udn) - discovery_infos = [ + + # Discovered via step ssdp. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: location, + ssdp.ATTR_SSDP_ST: mock_device.device_type, + ssdp.ATTR_SSDP_USN: mock_device.usn, + # ssdp.ATTR_UPNP_UDN: mock_device.udn, # Not provided. + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "incomplete_discovery" + + +async def test_flow_ssdp_discovery_ignored(hass: HomeAssistantType): + """Test config flow: discovery through ssdp, but ignored.""" + udn = "uuid:device_random_1" + location = "dummy" + mock_device = MockDevice(udn) + + # Existing entry. + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONFIG_ENTRY_UDN: "uuid:device_random_2", + CONFIG_ENTRY_ST: mock_device.device_type, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + }, + options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + config_entry.add_to_hass(hass) + + discoveries = [ { + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] + with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): - # Discovered via step ssdp. + Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) + ): + # Discovered via step ssdp, but ignored. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data={ + ssdp.ATTR_SSDP_LOCATION: location, ssdp.ATTR_SSDP_ST: mock_device.device_type, - # ssdp.ATTR_UPNP_UDN: mock_device.udn, # Not provided. - "friendlyName": mock_device.name, + ssdp.ATTR_SSDP_USN: mock_device.usn, + ssdp.ATTR_UPNP_UDN: mock_device.udn, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "incomplete_discovery" + assert result["reason"] == "discovery_ignored" async def test_flow_user(hass: HomeAssistantType): """Test config flow: discovered + configured through user.""" udn = "uuid:device_1" + location = "dummy" mock_device = MockDevice(udn) - discovery_infos = [ + discoveries = [ { - DISCOVERY_USN: mock_device.unique_id, + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] with patch.object( Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): + ), patch.object( + Device, "async_discover", AsyncMock(return_value=discoveries) + ), patch.object( + Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) + ): # Discovered via step user. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -120,7 +185,7 @@ async def test_flow_user(hass: HomeAssistantType): # Confirmed via step user. result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"usn": mock_device.unique_id}, + user_input={"unique_id": mock_device.unique_id}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -128,6 +193,7 @@ async def test_flow_user(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, } @@ -135,18 +201,26 @@ async def test_flow_import(hass: HomeAssistantType): """Test config flow: discovered + configured through configuration.yaml.""" udn = "uuid:device_1" mock_device = MockDevice(udn) - discovery_infos = [ + location = "dummy" + discoveries = [ { - DISCOVERY_USN: mock_device.unique_id, + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] with patch.object( Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): + ), patch.object( + Device, "async_discover", AsyncMock(return_value=discoveries) + ), patch.object( + Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) + ): # Discovered via step import. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} @@ -157,21 +231,14 @@ async def test_flow_import(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, } -async def test_flow_import_duplicate(hass: HomeAssistantType): +async def test_flow_import_already_configured(hass: HomeAssistantType): """Test config flow: discovered, but already configured.""" udn = "uuid:device_1" mock_device = MockDevice(udn) - discovery_infos = [ - { - DISCOVERY_USN: mock_device.unique_id, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", - } - ] # Existing entry. config_entry = MockConfigEntry( @@ -179,38 +246,39 @@ async def test_flow_import_duplicate(hass: HomeAssistantType): data={ CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_ST: mock_device.device_type, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) config_entry.add_to_hass(hass) - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) + # Discovered via step import. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_flow_import_incomplete(hass: HomeAssistantType): """Test config flow: incomplete discovery, configured through configuration.yaml.""" udn = "uuid:device_1" mock_device = MockDevice(udn) - discovery_infos = [ + location = "dummy" + discoveries = [ { - DISCOVERY_ST: mock_device.device_type, + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, + # DISCOVERY_ST: mock_device.device_type, DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): + with patch.object(Device, "async_discover", AsyncMock(return_value=discoveries)): # Discovered via step import. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} @@ -224,12 +292,17 @@ async def test_options_flow(hass: HomeAssistantType): """Test options flow.""" # Set up config entry. udn = "uuid:device_1" + location = "http://192.168.1.1/desc.xml" mock_device = MockDevice(udn) - discovery_infos = [ + discoveries = [ { - DISCOVERY_UDN: mock_device.udn, + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, - DISCOVERY_LOCATION: "http://192.168.1.1/desc.xml", + DISCOVERY_UDN: mock_device.udn, + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] config_entry = MockConfigEntry( @@ -237,6 +310,7 @@ async def test_options_flow(hass: HomeAssistantType): data={ CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_ST: mock_device.device_type, + CONFIG_ENTRY_HOSTNAME: mock_device.hostname, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -245,16 +319,15 @@ async def test_options_flow(hass: HomeAssistantType): config = { # no upnp, ensures no import-flow is started. } - async_discover = AsyncMock(return_value=discovery_infos) with patch.object( Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", async_discover): + ), patch.object(Device, "async_discover", AsyncMock(return_value=discoveries)): # Initialisation of component. await async_setup_component(hass, "upnp", config) await hass.async_block_till_done() # DataUpdateCoordinator gets a default of 30 seconds for updates. - coordinator = hass.data[DOMAIN]["coordinators"][mock_device.udn] + coordinator = hass.data[DOMAIN][DOMAIN_COORDINATORS][mock_device.udn] assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL) # Options flow with no input results in form. diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 4373e175bc9..086fbd677ab 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -2,14 +2,20 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components import upnp from homeassistant.components.upnp.const import ( + CONFIG_ENTRY_ST, + CONFIG_ENTRY_UDN, + DISCOVERY_HOSTNAME, DISCOVERY_LOCATION, + DISCOVERY_NAME, DISCOVERY_ST, DISCOVERY_UDN, + DISCOVERY_UNIQUE_ID, + DISCOVERY_USN, + DOMAIN, ) from homeassistant.components.upnp.device import Device -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.setup import async_setup_component from .mock_device import MockDevice @@ -17,38 +23,105 @@ from .mock_device import MockDevice from tests.common import MockConfigEntry -async def test_async_setup_entry_default(hass): +async def test_async_setup_entry_default(hass: HomeAssistantType): """Test async_setup_entry.""" udn = "uuid:device_1" + location = "http://192.168.1.1/desc.xml" mock_device = MockDevice(udn) - discovery_infos = [ + discoveries = [ { - DISCOVERY_UDN: mock_device.udn, + DISCOVERY_LOCATION: location, + DISCOVERY_NAME: mock_device.name, DISCOVERY_ST: mock_device.device_type, - DISCOVERY_LOCATION: "http://192.168.1.1/desc.xml", + DISCOVERY_UDN: mock_device.udn, + DISCOVERY_UNIQUE_ID: mock_device.unique_id, + DISCOVERY_USN: mock_device.usn, + DISCOVERY_HOSTNAME: mock_device.hostname, } ] entry = MockConfigEntry( - domain=upnp.DOMAIN, data={"udn": mock_device.udn, "st": mock_device.device_type} + domain=DOMAIN, + data={ + CONFIG_ENTRY_UDN: mock_device.udn, + CONFIG_ENTRY_ST: mock_device.device_type, + }, ) config = { # no upnp } - async_discover = AsyncMock(return_value=[]) - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", async_discover): + async_create_device = AsyncMock(return_value=mock_device) + async_discover = AsyncMock() + with patch.object(Device, "async_create_device", async_create_device), patch.object( + Device, "async_discover", async_discover + ): # initialisation of component, no device discovered + async_discover.return_value = [] await async_setup_component(hass, "upnp", config) await hass.async_block_till_done() # loading of config_entry, device discovered - async_discover.return_value = discovery_infos - assert await upnp.async_setup_entry(hass, entry) is True + async_discover.return_value = discoveries + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True # ensure device is stored/used - assert hass.data[upnp.DOMAIN]["devices"][udn] == mock_device + async_create_device.assert_called_with(hass, discoveries[0][DISCOVERY_LOCATION]) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + +async def test_sync_setup_entry_multiple_discoveries(hass: HomeAssistantType): + """Test async_setup_entry.""" + udn_0 = "uuid:device_1" + location_0 = "http://192.168.1.1/desc.xml" + mock_device_0 = MockDevice(udn_0) + udn_1 = "uuid:device_2" + location_1 = "http://192.168.1.2/desc.xml" + mock_device_1 = MockDevice(udn_1) + discoveries = [ + { + DISCOVERY_LOCATION: location_0, + DISCOVERY_NAME: mock_device_0.name, + DISCOVERY_ST: mock_device_0.device_type, + DISCOVERY_UDN: mock_device_0.udn, + DISCOVERY_UNIQUE_ID: mock_device_0.unique_id, + DISCOVERY_USN: mock_device_0.usn, + DISCOVERY_HOSTNAME: mock_device_0.hostname, + }, + { + DISCOVERY_LOCATION: location_1, + DISCOVERY_NAME: mock_device_1.name, + DISCOVERY_ST: mock_device_1.device_type, + DISCOVERY_UDN: mock_device_1.udn, + DISCOVERY_UNIQUE_ID: mock_device_1.unique_id, + DISCOVERY_USN: mock_device_1.usn, + DISCOVERY_HOSTNAME: mock_device_1.hostname, + }, + ] + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONFIG_ENTRY_UDN: mock_device_1.udn, + CONFIG_ENTRY_ST: mock_device_1.device_type, + }, + ) + + config = { + # no upnp + } + async_create_device = AsyncMock(return_value=mock_device_1) + async_discover = AsyncMock() + with patch.object(Device, "async_create_device", async_create_device), patch.object( + Device, "async_discover", async_discover + ): + # initialisation of component, no device discovered + async_discover.return_value = [] + await async_setup_component(hass, "upnp", config) await hass.async_block_till_done() + + # loading of config_entry, device discovered + async_discover.return_value = discoveries + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True + + # ensure device is stored/used + async_create_device.assert_called_with(hass, discoveries[1][DISCOVERY_LOCATION]) diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 00c827b9973..1dd44625ebe 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -257,7 +257,7 @@ class TestUVC(unittest.TestCase): assert not self.uvc.is_recording assert ( datetime(2021, 1, 8, 1, 56, 32, 367000) - == self.uvc.state_attributes["last_recording_start_time"] + == self.uvc.device_state_attributes["last_recording_start_time"] ) self.nvr.get_camera.return_value["recordingIndicator"] = "DISABLED" diff --git a/tests/components/vera/test_binary_sensor.py b/tests/components/vera/test_binary_sensor.py index 1bcb8d1a183..b3b8d2d6ae1 100644 --- a/tests/components/vera/test_binary_sensor.py +++ b/tests/components/vera/test_binary_sensor.py @@ -14,6 +14,7 @@ async def test_binary_sensor( """Test function.""" vera_device = MagicMock(spec=pv.VeraBinarySensor) # type: pv.VeraBinarySensor vera_device.device_id = 1 + vera_device.comm_failure = False vera_device.vera_device_id = vera_device.device_id vera_device.name = "dev1" vera_device.is_tripped = False diff --git a/tests/components/vera/test_climate.py b/tests/components/vera/test_climate.py index 076b51997a0..5ec39f07953 100644 --- a/tests/components/vera/test_climate.py +++ b/tests/components/vera/test_climate.py @@ -23,6 +23,7 @@ async def test_climate( vera_device = MagicMock(spec=pv.VeraThermostat) # type: pv.VeraThermostat vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_THERMOSTAT vera_device.power = 10 @@ -133,6 +134,7 @@ async def test_climate_f( vera_device = MagicMock(spec=pv.VeraThermostat) # type: pv.VeraThermostat vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_THERMOSTAT vera_device.power = 10 diff --git a/tests/components/vera/test_cover.py b/tests/components/vera/test_cover.py index 0c05d84e2db..cfc33fb2dcf 100644 --- a/tests/components/vera/test_cover.py +++ b/tests/components/vera/test_cover.py @@ -15,6 +15,7 @@ async def test_cover( vera_device = MagicMock(spec=pv.VeraCurtain) # type: pv.VeraCurtain vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_CURTAIN vera_device.is_closed = False diff --git a/tests/components/vera/test_init.py b/tests/components/vera/test_init.py index b1d6010336a..33b6843d7e5 100644 --- a/tests/components/vera/test_init.py +++ b/tests/components/vera/test_init.py @@ -206,6 +206,7 @@ async def test_exclude_and_light_ids( vera_device3.name = "dev3" vera_device3.category = pv.CATEGORY_SWITCH vera_device3.is_switched_on = MagicMock(return_value=False) + entity_id3 = "switch.dev3_3" vera_device4 = MagicMock(spec=pv.VeraSwitch) # type: pv.VeraSwitch @@ -214,6 +215,10 @@ async def test_exclude_and_light_ids( vera_device4.name = "dev4" vera_device4.category = pv.CATEGORY_SWITCH vera_device4.is_switched_on = MagicMock(return_value=False) + vera_device4.get_brightness = MagicMock(return_value=0) + vera_device4.get_color = MagicMock(return_value=[0, 0, 0]) + vera_device4.is_dimmable = True + entity_id4 = "light.dev4_4" component_data = await vera_component_factory.configure_component( diff --git a/tests/components/vera/test_light.py b/tests/components/vera/test_light.py index 3b14aba7429..ad5ad7e0259 100644 --- a/tests/components/vera/test_light.py +++ b/tests/components/vera/test_light.py @@ -16,6 +16,7 @@ async def test_light( vera_device = MagicMock(spec=pv.VeraDimmer) # type: pv.VeraDimmer vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_DIMMER vera_device.is_switched_on = MagicMock(return_value=False) diff --git a/tests/components/vera/test_lock.py b/tests/components/vera/test_lock.py index c288ac8709e..171f799f87b 100644 --- a/tests/components/vera/test_lock.py +++ b/tests/components/vera/test_lock.py @@ -16,6 +16,7 @@ async def test_lock( vera_device = MagicMock(spec=pv.VeraLock) # type: pv.VeraLock vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_LOCK vera_device.is_locked = MagicMock(return_value=False) diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index 43777642816..62639df3a35 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -23,6 +23,7 @@ async def run_sensor_test( vera_device = MagicMock(spec=pv.VeraSensor) # type: pv.VeraSensor vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = category setattr(vera_device, class_property, "33") @@ -178,6 +179,7 @@ async def test_scene_controller_sensor( vera_device = MagicMock(spec=pv.VeraSensor) # type: pv.VeraSensor vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_SCENE_CONTROLLER vera_device.get_last_scene_id = MagicMock(return_value="id0") diff --git a/tests/components/vera/test_switch.py b/tests/components/vera/test_switch.py index b61564c56bc..ac90edc9ded 100644 --- a/tests/components/vera/test_switch.py +++ b/tests/components/vera/test_switch.py @@ -15,6 +15,7 @@ async def test_switch( vera_device = MagicMock(spec=pv.VeraSwitch) # type: pv.VeraSwitch vera_device.device_id = 1 vera_device.vera_device_id = vera_device.device_id + vera_device.comm_failure = False vera_device.name = "dev1" vera_device.category = pv.CATEGORY_SWITCH vera_device.is_switched_on = MagicMock(return_value=False) diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 917e6f7f291..8124827dbf0 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -157,6 +157,9 @@ def vizio_cant_connect_fixture(): with patch( "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", AsyncMock(return_value=False), + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", + return_value=None, ): yield diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index cd611662597..b223202d5b1 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.components.vizio.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers.typing import HomeAssistantType from homeassistant.setup import async_setup_component @@ -41,7 +42,10 @@ async def test_tv_load_and_unload( assert await config_entry.async_unload(hass) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0 + entities = hass.states.async_entity_ids(MP_DOMAIN) + assert len(entities) == 1 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE assert DOMAIN not in hass.data @@ -62,5 +66,8 @@ async def test_speaker_load_and_unload( assert await config_entry.async_unload(hass) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0 + entities = hass.states.async_entity_ids(MP_DOMAIN) + assert len(entities) == 1 + for entity in entities: + assert hass.states.get(entity).state == STATE_UNAVAILABLE assert DOMAIN not in hass.data diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 78976032b00..6d0ba2781e6 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -239,17 +239,6 @@ def _assert_source_list_with_apps( assert attr["source_list"] == list_to_test -async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None: - """Test generic Vizio entity setup failure.""" - with patch( - "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", - return_value=False, - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=UNIQUE_ID) - await _add_config_entry_to_hass(hass, config_entry) - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0 - - async def _test_service( hass: HomeAssistantType, domain: str, @@ -334,18 +323,28 @@ async def test_init_tv_unavailable( await _test_setup_tv(hass, None) -async def test_setup_failure_speaker( - hass: HomeAssistantType, vizio_connect: pytest.fixture +async def test_setup_unavailable_speaker( + hass: HomeAssistantType, vizio_cant_connect: pytest.fixture ) -> None: - """Test speaker entity setup failure.""" - await _test_setup_failure(hass, MOCK_SPEAKER_CONFIG) + """Test speaker entity sets up as unavailable.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID + ) + await _add_config_entry_to_hass(hass, config_entry) + assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert hass.states.get("media_player.vizio").state == STATE_UNAVAILABLE -async def test_setup_failure_tv( - hass: HomeAssistantType, vizio_connect: pytest.fixture +async def test_setup_unavailable_tv( + hass: HomeAssistantType, vizio_cant_connect: pytest.fixture ) -> None: - """Test TV entity setup failure.""" - await _test_setup_failure(hass, MOCK_USER_VALID_TV_CONFIG) + """Test TV entity sets up as unavailable.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID + ) + await _add_config_entry_to_hass(hass, config_entry) + assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert hass.states.get("media_player.vizio").state == STATE_UNAVAILABLE async def test_services( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a7aa17db6d3..1f7abc42c4e 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -52,6 +52,47 @@ async def test_call_service(hass, websocket_client): assert call.data == {"hello": "world"} +async def test_call_service_target(hass, websocket_client): + """Test call service command with target.""" + calls = [] + + @callback + def service_call(call): + calls.append(call) + + hass.services.async_register("domain_test", "test_service", service_call) + + await websocket_client.send_json( + { + "id": 5, + "type": "call_service", + "domain": "domain_test", + "service": "test_service", + "service_data": {"hello": "world"}, + "target": { + "entity_id": ["entity.one", "entity.two"], + "device_id": "deviceid", + }, + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert len(calls) == 1 + call = calls[0] + + assert call.domain == "domain_test" + assert call.service == "test_service" + assert call.data == { + "hello": "world", + "entity_id": ["entity.one", "entity.two"], + "device_id": ["deviceid"], + } + + async def test_call_service_not_found(hass, websocket_client): """Test call service command.""" await websocket_client.send_json( diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 0ecfc46d526..e584cb5fb39 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -6,6 +6,9 @@ import asyncio import threading from unittest.mock import patch +import async_timeout +from pywemo.ouimeaux_device.api.service import ActionException + from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -127,7 +130,7 @@ async def test_async_locked_update_with_exception( assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF await async_setup_component(hass, HA_DOMAIN, {}) update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = AttributeError + update_polling_method.side_effect = ActionException await hass.services.async_call( HA_DOMAIN, @@ -137,7 +140,6 @@ async def test_async_locked_update_with_exception( ) assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - pywemo_device.reconnect_with_device.assert_called_with() async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_device): @@ -145,7 +147,19 @@ async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_ assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF await async_setup_component(hass, HA_DOMAIN, {}) - with patch("async_timeout.timeout", side_effect=asyncio.TimeoutError): + event = threading.Event() + + def get_state(*args): + event.wait() + return 0 + + if hasattr(pywemo_device, "bridge_update"): + pywemo_device.bridge_update.side_effect = get_state + else: + pywemo_device.get_state.side_effect = get_state + timeout = async_timeout.timeout(0) + + with patch("async_timeout.timeout", return_value=timeout): await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -156,11 +170,6 @@ async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_ assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE # Check that the entity recovers and is available after the update succeeds. - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - + event.set() + await hass.async_block_till_done() assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 374222d8688..7c2b43dfd8c 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -100,28 +100,41 @@ async def test_static_config_with_invalid_host(hass): async def test_discovery(hass, pywemo_registry): """Verify that discovery dispatches devices to the platform for setup.""" - def create_device(counter): + def create_device(uuid, location): """Create a unique mock Motion detector device for each counter value.""" device = create_autospec(pywemo.Motion, instance=True) - device.host = f"{MOCK_HOST}_{counter}" - device.port = MOCK_PORT + counter - device.name = f"{MOCK_NAME}_{counter}" - device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}" + device.host = location + device.port = MOCK_PORT + device.name = f"{MOCK_NAME}_{uuid}" + device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{uuid}" device.model_name = "Motion" device.get_state.return_value = 0 # Default to Off return device - pywemo_devices = [create_device(0), create_device(1)] + def create_upnp_entry(counter): + return pywemo.ssdp.UPNPEntry.from_response( + "\r\n".join( + [ + "", + f"LOCATION: http://192.168.1.100:{counter}/setup.xml", + f"USN: uuid:Socket-1_0-SERIAL{counter}::upnp:rootdevice", + "", + ] + ) + ) + + upnp_entries = [create_upnp_entry(0), create_upnp_entry(1)] # Setup the component and start discovery. with patch( - "pywemo.discover_devices", return_value=pywemo_devices - ) as mock_discovery: + "pywemo.discovery.device_from_uuid_and_location", side_effect=create_device + ), patch("pywemo.ssdp.scan", return_value=upnp_entries) as mock_scan: assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}} ) await pywemo_registry.semaphore.acquire() # Returns after platform setup. - mock_discovery.assert_called() - pywemo_devices.append(create_device(2)) + mock_scan.assert_called() + # Add two of the same entries to test deduplication. + upnp_entries.extend([create_upnp_entry(2), create_upnp_entry(2)]) # Test that discovery runs periodically and the async_dispatcher_send code works. async_fire_time_changed( diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 3e7f79200c6..573f75a66d9 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -69,9 +69,10 @@ async def test_async_update_with_timeout_and_recovery( hass, pywemo_bridge_light, wemo_entity, pywemo_device ): """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device - ) + with _bypass_throttling(): + await entity_test_helpers.test_async_update_with_timeout_and_recovery( + hass, wemo_entity, pywemo_device + ) async def test_async_locked_update_with_exception( diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index e1c31345235..7ee7f0119a4 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -1,4 +1,7 @@ """Tests for the WiLight component.""" + +from pywilight.const import DOMAIN + from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER, @@ -10,7 +13,6 @@ from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) -from homeassistant.components.wilight.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.helpers.typing import HomeAssistantType @@ -24,6 +26,7 @@ UPNP_MODEL_NAME_P_B = "WiLight 0102001800010009-10010010" UPNP_MODEL_NAME_DIMMER = "WiLight 0100001700020009-10010010" UPNP_MODEL_NAME_COLOR = "WiLight 0107001800020009-11010" UPNP_MODEL_NAME_LIGHT_FAN = "WiLight 0104001800010009-10" +UPNP_MODEL_NAME_COVER = "WiLight 0103001800010009-10" UPNP_MODEL_NUMBER = "123456789012345678901234567890123456" UPNP_SERIAL = "000000000099" UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" @@ -53,14 +56,6 @@ MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER = { ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, } -MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_LIGHT_FAN, - ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, -} - async def setup_integration( hass: HomeAssistantType, diff --git a/tests/components/wilight/test_config_flow.py b/tests/components/wilight/test_config_flow.py index d44780092ec..9888dbe3ef9 100644 --- a/tests/components/wilight/test_config_flow.py +++ b/tests/components/wilight/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import patch import pytest +from pywilight.const import DOMAIN from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) -from homeassistant.components.wilight.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.data_entry_flow import ( diff --git a/tests/components/wilight/test_cover.py b/tests/components/wilight/test_cover.py new file mode 100644 index 00000000000..85f62c9d120 --- /dev/null +++ b/tests/components/wilight/test_cover.py @@ -0,0 +1,136 @@ +"""Tests for the WiLight integration.""" +from unittest.mock import patch + +import pytest +import pywilight + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + HOST, + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_COVER, + UPNP_MODEL_NUMBER, + UPNP_SERIAL, + WILIGHT_ID, + setup_integration, +) + + +@pytest.fixture(name="dummy_device_from_host_cover") +def mock_dummy_device_from_host_light_fan(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_COVER, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", + return_value=device, + ): + yield device + + +async def test_loading_cover( + hass: HomeAssistantType, + dummy_device_from_host_cover, +) -> None: + """Test the WiLight configuration entry loading.""" + + entry = await setup_integration(hass) + assert entry + assert entry.unique_id == WILIGHT_ID + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # First segment of the strip + state = hass.states.get("cover.wl000000000099_1") + assert state + assert state.state == STATE_CLOSED + + entry = entity_registry.async_get("cover.wl000000000099_1") + assert entry + assert entry.unique_id == "WL000000000099_0" + + +async def test_open_close_cover_state( + hass: HomeAssistantType, dummy_device_from_host_cover +) -> None: + """Test the change of state of the cover.""" + await setup_integration(hass) + + # Open + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("cover.wl000000000099_1") + assert state + assert state.state == STATE_OPENING + + # Close + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("cover.wl000000000099_1") + assert state + assert state.state == STATE_CLOSING + + # Set position + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50, ATTR_ENTITY_ID: "cover.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("cover.wl000000000099_1") + assert state + assert state.state == STATE_OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 50 + + # Stop + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("cover.wl000000000099_1") + assert state + assert state.state == STATE_OPEN diff --git a/tests/components/wilight/test_fan.py b/tests/components/wilight/test_fan.py index 9b656236b93..1247b622ae7 100644 --- a/tests/components/wilight/test_fan.py +++ b/tests/components/wilight/test_fan.py @@ -6,15 +6,12 @@ import pywilight from homeassistant.components.fan import ( ATTR_DIRECTION, - ATTR_SPEED, + ATTR_PERCENTAGE, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN as FAN_DOMAIN, SERVICE_SET_DIRECTION, - SERVICE_SET_SPEED, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, + SERVICE_SET_PERCENTAGE, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -102,7 +99,7 @@ async def test_on_off_fan_state( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_SPEED: SPEED_LOW, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, + {ATTR_PERCENTAGE: 30, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, blocking=True, ) @@ -110,7 +107,7 @@ async def test_on_off_fan_state( state = hass.states.get("fan.wl000000000099_2") assert state assert state.state == STATE_ON - assert state.attributes.get(ATTR_SPEED) == SPEED_LOW + assert state.attributes.get(ATTR_PERCENTAGE) == 33 # Turn off await hass.services.async_call( @@ -135,41 +132,41 @@ async def test_speed_fan_state( # Set speed Low await hass.services.async_call( FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_LOW, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 30, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("fan.wl000000000099_2") assert state - assert state.attributes.get(ATTR_SPEED) == SPEED_LOW + assert state.attributes.get(ATTR_PERCENTAGE) == 33 # Set speed Medium await hass.services.async_call( FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_MEDIUM, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 50, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("fan.wl000000000099_2") assert state - assert state.attributes.get(ATTR_SPEED) == SPEED_MEDIUM + assert state.attributes.get(ATTR_PERCENTAGE) == 66 # Set speed High await hass.services.async_call( FAN_DOMAIN, - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_HIGH, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 90, ATTR_ENTITY_ID: "fan.wl000000000099_2"}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("fan.wl000000000099_2") assert state - assert state.attributes.get(ATTR_SPEED) == SPEED_HIGH + assert state.attributes.get(ATTR_PERCENTAGE) == 100 async def test_direction_fan_state( diff --git a/tests/components/wilight/test_init.py b/tests/components/wilight/test_init.py index c1557fb44d3..1441564b640 100644 --- a/tests/components/wilight/test_init.py +++ b/tests/components/wilight/test_init.py @@ -3,8 +3,8 @@ from unittest.mock import patch import pytest import pywilight +from pywilight.const import DOMAIN -from homeassistant.components.wilight.const import DOMAIN from homeassistant.config_entries import ( ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED, diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 280775a7130..f52f9b8e64d 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -7,7 +7,7 @@ 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 +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" @@ -107,7 +107,7 @@ async def test_config_flow_user_success(hass): CONF_PORT: TEST_PORT, CONF_MAC: TEST_MAC, const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, - const.CONF_PROTOCOL: TEST_PROTOCOL, + CONF_PROTOCOL: TEST_PROTOCOL, const.CONF_KEY: TEST_KEY, const.CONF_SID: TEST_SID, } @@ -159,7 +159,7 @@ async def test_config_flow_user_multiple_success(hass): CONF_PORT: TEST_PORT, CONF_MAC: TEST_MAC, const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, - const.CONF_PROTOCOL: TEST_PROTOCOL, + CONF_PROTOCOL: TEST_PROTOCOL, const.CONF_KEY: TEST_KEY, const.CONF_SID: TEST_SID, } @@ -196,7 +196,7 @@ async def test_config_flow_user_no_key_success(hass): CONF_PORT: TEST_PORT, CONF_MAC: TEST_MAC, const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, - const.CONF_PROTOCOL: TEST_PROTOCOL, + CONF_PROTOCOL: TEST_PROTOCOL, const.CONF_KEY: None, const.CONF_SID: TEST_SID, } @@ -243,7 +243,7 @@ async def test_config_flow_user_host_mac_success(hass): CONF_PORT: TEST_PORT, CONF_MAC: TEST_MAC, const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, - const.CONF_PROTOCOL: TEST_PROTOCOL, + CONF_PROTOCOL: TEST_PROTOCOL, const.CONF_KEY: None, const.CONF_SID: TEST_SID, } @@ -433,7 +433,7 @@ async def test_zeroconf_success(hass): CONF_PORT: TEST_PORT, CONF_MAC: TEST_MAC, const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, - const.CONF_PROTOCOL: TEST_PROTOCOL, + CONF_PROTOCOL: TEST_PROTOCOL, const.CONF_KEY: TEST_KEY, const.CONF_SID: TEST_SID, } diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index dbe78957586..f53fe6e40b4 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -5,7 +5,8 @@ from miio import DeviceException from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.xiaomi_miio import config_flow, const +from homeassistant.components.xiaomi_miio import const +from homeassistant.components.xiaomi_miio.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN ZEROCONF_NAME = "name" @@ -15,8 +16,9 @@ ZEROCONF_MAC = "mac" TEST_HOST = "1.2.3.4" TEST_TOKEN = "12345678901234567890123456789012" TEST_NAME = "Test_Gateway" -TEST_MODEL = "model5" +TEST_MODEL = const.MODELS_GATEWAY[0] TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_MAC_DEVICE = "abcdefghijkl" TEST_GATEWAY_ID = TEST_MAC TEST_HARDWARE_VERSION = "AB123" TEST_FIRMWARE_VERSION = "1.2.3_456" @@ -40,26 +42,6 @@ def get_mock_info( return gateway_info -async def test_config_flow_step_user_no_device(hass): - """Test config flow, user step with no device selected.""" - result = await hass.config_entries.flow.async_init( - const.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"], - {}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "no_device_selected"} - - async def test_config_flow_step_gateway_connect_error(hass): """Test config flow, gateway connection error.""" result = await hass.config_entries.flow.async_init( @@ -67,29 +49,20 @@ async def test_config_flow_step_gateway_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {config_flow.CONF_GATEWAY: True}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", side_effect=DeviceException({}), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {"base": "cannot_connect"} @@ -100,42 +73,30 @@ async def test_config_flow_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {config_flow.CONF_GATEWAY: True}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} mock_info = get_mock_info() with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.discover_devices", - return_value=TEST_SUB_DEVICE_LIST, ), patch( "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME + assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { - config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - "model": TEST_MODEL, - "mac": TEST_MAC, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, } @@ -152,33 +113,30 @@ async def test_zeroconf_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} mock_info = get_mock_info() with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.discover_devices", - return_value=TEST_SUB_DEVICE_LIST, ), patch( "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME + assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { - config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - "model": TEST_MODEL, - "mac": TEST_MAC, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, } @@ -218,3 +176,227 @@ async def test_zeroconf_missing_data(hass): assert result["type"] == "abort" assert result["reason"] == "not_xiaomi_miio" + + +async def test_config_flow_step_device_connect_error(hass): + """Test config flow, device connection error.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=DeviceException({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_config_flow_step_unknown_device(hass): + """Test config flow, unknown device error.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model="UNKNOWN") + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {"base": "unknown_device"} + + +async def test_import_flow_success(hass): + """Test a successful import form yaml for a device.""" + mock_info = get_mock_info(model=const.MODELS_SWITCH[0]) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_NAME: TEST_NAME, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: const.MODELS_SWITCH[0], + const.CONF_MAC: TEST_MAC, + } + + +async def test_config_flow_step_device_manual_model_succes(hass): + """Test config flow, device connection error, manual model.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=DeviceException({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {"base": "cannot_connect"} + + overwrite_model = const.MODELS_VACUUM[0] + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=DeviceException({}), + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: overwrite_model}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == overwrite_model + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: overwrite_model, + const.CONF_MAC: None, + } + + +async def config_flow_device_success(hass, model_to_test): + """Test a successful config flow for a device (base class).""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model=model_to_test) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == model_to_test + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: model_to_test, + const.CONF_MAC: TEST_MAC, + } + + +async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): + """Test a successful zeroconf discovery of a device (base class).""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + zeroconf.ATTR_HOST: TEST_HOST, + ZEROCONF_NAME: zeroconf_name_to_test, + ZEROCONF_PROP: {"poch": f"0:mac={TEST_MAC_DEVICE}\x00"}, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model=model_to_test) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == model_to_test + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: model_to_test, + const.CONF_MAC: TEST_MAC, + } + + +async def test_config_flow_plug_success(hass): + """Test a successful config flow for a plug.""" + test_plug_model = const.MODELS_SWITCH[0] + await config_flow_device_success(hass, test_plug_model) + + +async def test_zeroconf_plug_success(hass): + """Test a successful zeroconf discovery of a plug.""" + test_plug_model = const.MODELS_SWITCH[0] + test_zeroconf_name = const.MODELS_SWITCH[0].replace(".", "-") + await zeroconf_device_success(hass, test_zeroconf_name, test_plug_model) + + +async def test_config_flow_vacuum_success(hass): + """Test a successful config flow for a vacuum.""" + test_vacuum_model = const.MODELS_VACUUM[0] + await config_flow_device_success(hass, test_vacuum_model) + + +async def test_zeroconf_vacuum_success(hass): + """Test a successful zeroconf discovery of a vacuum.""" + test_vacuum_model = const.MODELS_VACUUM[0] + test_zeroconf_name = const.MODELS_VACUUM[0].replace(".", "-") + await zeroconf_device_success(hass, test_zeroconf_name, test_vacuum_model) diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index b1a3c08b84b..23e5d8884b3 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -22,6 +22,7 @@ from homeassistant.components.vacuum import ( STATE_CLEANING, STATE_ERROR, ) +from homeassistant.components.xiaomi_miio import const from homeassistant.components.xiaomi_miio.const import DOMAIN as XIAOMI_DOMAIN from homeassistant.components.xiaomi_miio.vacuum import ( ATTR_CLEANED_AREA, @@ -38,7 +39,6 @@ from homeassistant.components.xiaomi_miio.vacuum import ( ATTR_SIDE_BRUSH_LEFT, ATTR_TIMERS, CONF_HOST, - CONF_NAME, CONF_TOKEN, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, @@ -51,12 +51,14 @@ from homeassistant.components.xiaomi_miio.vacuum import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component + +from .test_config_flow import TEST_MAC + +from tests.common import MockConfigEntry PLATFORM = "xiaomi_miio" @@ -521,17 +523,21 @@ async def setup_component(hass, entity_name): """Set up vacuum component.""" entity_id = f"{DOMAIN}.{entity_name}" - await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_PLATFORM: PLATFORM, - CONF_HOST: "192.168.1.100", - CONF_NAME: entity_name, - CONF_TOKEN: "12345678901234567890123456789012", - } + config_entry = MockConfigEntry( + domain=XIAOMI_DOMAIN, + unique_id="123456", + title=entity_name, + data={ + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: "192.168.1.100", + CONF_TOKEN: "12345678901234567890123456789012", + const.CONF_MODEL: const.MODELS_VACUUM[0], + const.CONF_MAC: TEST_MAC, }, ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return entity_id diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8fa1ba5c988..6a1508d7896 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries from homeassistant.components.yeelight import ( - CONF_DEVICE, CONF_MODE_MUSIC, CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, @@ -18,7 +17,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from . import ( diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index c91ae33d986..05a0bd0d8d4 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -11,7 +11,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component @@ -50,6 +50,12 @@ async def test_setup_discovery(hass: HomeAssistant): # Unload assert await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + # Remove + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert hass.states.get(ENTITY_BINARY_SENSOR) is None assert hass.states.get(ENTITY_LIGHT) is None diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index fe65def839d..b3dbefbdbf0 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -74,6 +74,7 @@ async def test_user_flow_not_detected(detect_mock, hass): assert detect_mock.await_args[0][0] == port.device +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) async def test_user_flow_show_form(hass): """Test user step form.""" result = await hass.config_entries.flow.async_init( @@ -85,6 +86,17 @@ async def test_user_flow_show_form(hass): assert result["step_id"] == "user" +async def test_user_flow_show_manual(hass): + """Test user flow manual entry when no comport detected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pick_radio" + + async def test_user_flow_manual(hass): """Test user flow manual entry.""" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 61828c135bc..81a441a4101 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, call, patch import pytest +from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.hvac as hvac @@ -9,17 +10,27 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components import fan from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, + NotValidPresetModeError, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.fan import ( + PRESET_MODE_AUTO, + PRESET_MODE_ON, + PRESET_MODE_SMART, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -173,6 +184,20 @@ async def test_fan(hass, zha_device_joined_restored, zigpy_device): assert len(cluster.write_attributes.mock_calls) == 1 assert cluster.write_attributes.call_args == call({"fan_mode": 3}) + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 4}) + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode( + hass, entity_id, preset_mode="invalid does not exist" + ) + assert len(cluster.write_attributes.mock_calls) == 0 + # test adding new fan to the network and HA await async_test_rejoin(hass, zigpy_device, [cluster], (1,)) @@ -206,6 +231,17 @@ async def async_set_speed(hass, entity_id, speed=None): await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True) +async def async_set_preset_mode(hass, entity_id, preset_mode=None): + """Set preset_mode for specified fan.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True) + + @patch( "zigpy.zcl.clusters.hvac.Fan.write_attributes", new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]), @@ -276,6 +312,24 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato assert len(group_fan_cluster.write_attributes.mock_calls) == 1 assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 3} + # change preset mode from HA + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4} + + # change preset mode from HA + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5} + + # change preset mode from HA + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 6} + # test some of the group logic to make sure we key off states correctly await send_attributes_report(hass, dev1_fan_cluster, {0: 0}) await send_attributes_report(hass, dev2_fan_cluster, {0: 0}) @@ -296,14 +350,74 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato assert hass.states.get(entity_id).state == STATE_OFF +@patch( + "zigpy.zcl.clusters.hvac.Fan.write_attributes", + new=AsyncMock(side_effect=ZigbeeException), +) +async def test_zha_group_fan_entity_failure_state( + hass, device_fan_1, device_fan_2, coordinator, caplog +): + """Test the fan entity for a ZHA group when writing attributes generates an exception.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_fan_1._zha_gateway = zha_gateway + device_fan_2._zha_gateway = zha_gateway + member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee] + members = [GroupMember(device_fan_1.ieee, 1), GroupMember(device_fan_2.ieee, 1)] + + # test creating a group with 2 members + zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await hass.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group) + assert len(entity_domains) == 2 + + assert LIGHT_DOMAIN in entity_domains + assert DOMAIN in entity_domains + + entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group) + assert hass.states.get(entity_id) is not None + + group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id] + + await async_enable_traffic(hass, [device_fan_1, device_fan_2], enabled=False) + await hass.async_block_till_done() + # test that the fans were created and that they are unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [device_fan_1, device_fan_2]) + + # test that the fan group entity was created and is off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + group_fan_cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + await hass.async_block_till_done() + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2} + + assert "Could not set fan mode" in caplog.text + + @pytest.mark.parametrize( - "plug_read, expected_state, expected_speed", + "plug_read, expected_state, expected_speed, expected_percentage", ( - (None, STATE_OFF, None), - ({"fan_mode": 0}, STATE_OFF, SPEED_OFF), - ({"fan_mode": 1}, STATE_ON, SPEED_LOW), - ({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM), - ({"fan_mode": 3}, STATE_ON, SPEED_HIGH), + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, SPEED_OFF, 0), + ({"fan_mode": 1}, STATE_ON, SPEED_LOW, 33), + ({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM, 66), + ({"fan_mode": 3}, STATE_ON, SPEED_HIGH, 100), ), ) async def test_fan_init( @@ -313,6 +427,7 @@ async def test_fan_init( plug_read, expected_state, expected_speed, + expected_percentage, ): """Test zha fan platform.""" @@ -324,6 +439,8 @@ async def test_fan_init( assert entity_id is not None assert hass.states.get(entity_id).state == expected_state assert hass.states.get(entity_id).attributes[ATTR_SPEED] == expected_speed + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None async def test_fan_update_entity( @@ -341,6 +458,9 @@ async def test_fan_update_entity( assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 assert cluster.read_attributes.await_count == 1 await async_setup_component(hass, "homeassistant", {}) @@ -358,5 +478,8 @@ async def test_fan_update_entity( "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 33 assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_LOW + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 assert cluster.read_attributes.await_count == 3 diff --git a/tests/components/zwave/test_fan.py b/tests/components/zwave/test_fan.py index e5dac881ba2..18188cefcd6 100644 --- a/tests/components/zwave/test_fan.py +++ b/tests/components/zwave/test_fan.py @@ -39,7 +39,7 @@ def test_fan_turn_on(mock_openzwave): node.reset_mock() - device.turn_on(speed=SPEED_OFF) + device.turn_on(percentage=0) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] @@ -49,7 +49,7 @@ def test_fan_turn_on(mock_openzwave): node.reset_mock() - device.turn_on(speed=SPEED_LOW) + device.turn_on(percentage=1) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] @@ -59,7 +59,7 @@ def test_fan_turn_on(mock_openzwave): node.reset_mock() - device.turn_on(speed=SPEED_MEDIUM) + device.turn_on(percentage=50) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] @@ -69,7 +69,7 @@ def test_fan_turn_on(mock_openzwave): node.reset_mock() - device.turn_on(speed=SPEED_HIGH) + device.turn_on(percentage=100) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 63ec9013fa3..a5ee628754e 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -2,7 +2,7 @@ AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" -SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports_current_value" +SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" @@ -13,3 +13,9 @@ NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_s PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) +CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat" +CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat" +CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat" +BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" +EATON_RF9640_ENTITY = "light.allloaddimmer" +AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 9cb950ba6e7..50cacd97422 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -1,7 +1,8 @@ """Provide common Z-Wave JS fixtures.""" import asyncio +import copy import json -from unittest.mock import DEFAULT, AsyncMock, patch +from unittest.mock import AsyncMock, patch import pytest from zwave_js_server.event import Event @@ -9,40 +10,133 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo -from homeassistant.helpers.device_registry import ( - async_get_registry as async_get_device_registry, -) +from homeassistant.helpers.device_registry import async_get as async_get_device_registry from tests.common import MockConfigEntry, load_fixture +# Add-on fixtures + + +@pytest.fixture(name="addon_info_side_effect") +def addon_info_side_effect_fixture(): + """Return the add-on info side effect.""" + return None + + +@pytest.fixture(name="addon_info") +def mock_addon_info(addon_info_side_effect): + """Mock Supervisor add-on info.""" + with patch( + "homeassistant.components.zwave_js.addon.async_get_addon_info", + side_effect=addon_info_side_effect, + ) as addon_info: + addon_info.return_value = {} + yield addon_info + + +@pytest.fixture(name="addon_running") +def mock_addon_running(addon_info): + """Mock add-on already running.""" + addon_info.return_value["state"] = "started" + return addon_info + + +@pytest.fixture(name="addon_installed") +def mock_addon_installed(addon_info): + """Mock add-on already installed but not running.""" + addon_info.return_value["state"] = "stopped" + addon_info.return_value["version"] = "1.0" + return addon_info + + +@pytest.fixture(name="addon_options") +def mock_addon_options(addon_info): + """Mock add-on options.""" + addon_info.return_value["options"] = {} + return addon_info.return_value["options"] + + +@pytest.fixture(name="set_addon_options_side_effect") +def set_addon_options_side_effect_fixture(): + """Return the set add-on options side effect.""" + return None + + +@pytest.fixture(name="set_addon_options") +def mock_set_addon_options(set_addon_options_side_effect): + """Mock set add-on options.""" + with patch( + "homeassistant.components.zwave_js.addon.async_set_addon_options", + side_effect=set_addon_options_side_effect, + ) as set_options: + yield set_options + + +@pytest.fixture(name="install_addon") +def mock_install_addon(): + """Mock install add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_install_addon" + ) as install_addon: + yield install_addon + + +@pytest.fixture(name="update_addon") +def mock_update_addon(): + """Mock update add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_update_addon" + ) as update_addon: + yield update_addon + + +@pytest.fixture(name="start_addon_side_effect") +def start_addon_side_effect_fixture(): + """Return the set add-on options side effect.""" + return None + + +@pytest.fixture(name="start_addon") +def mock_start_addon(start_addon_side_effect): + """Mock start add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_start_addon", + side_effect=start_addon_side_effect, + ) as start_addon: + yield start_addon + + +@pytest.fixture(name="stop_addon") +def stop_addon_fixture(): + """Mock stop add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_stop_addon" + ) as stop_addon: + yield stop_addon + + +@pytest.fixture(name="uninstall_addon") +def uninstall_addon_fixture(): + """Mock uninstall add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_uninstall_addon" + ) as uninstall_addon: + yield uninstall_addon + + +@pytest.fixture(name="create_shapshot") +def create_snapshot_fixture(): + """Mock create snapshot.""" + with patch( + "homeassistant.components.zwave_js.addon.async_create_snapshot" + ) as create_shapshot: + yield create_shapshot + @pytest.fixture(name="device_registry") async def device_registry_fixture(hass): """Return the device registry.""" - return await async_get_device_registry(hass) - - -@pytest.fixture(name="discovery_info") -def discovery_info_fixture(): - """Return the discovery info from the supervisor.""" - return DEFAULT - - -@pytest.fixture(name="discovery_info_side_effect") -def discovery_info_side_effect_fixture(): - """Return the discovery info from the supervisor.""" - return None - - -@pytest.fixture(name="get_addon_discovery_info") -def mock_get_addon_discovery_info(discovery_info, discovery_info_side_effect): - """Mock get add-on discovery info.""" - with patch( - "homeassistant.components.hassio.async_get_addon_discovery_info", - side_effect=discovery_info_side_effect, - return_value=discovery_info, - ) as get_addon_discovery_info: - yield get_addon_discovery_info + return async_get_device_registry(hass) @pytest.fixture(name="controller_state", scope="session") @@ -158,6 +252,36 @@ def in_wall_smart_fan_control_state_fixture(): return json.loads(load_fixture("zwave_js/in_wall_smart_fan_control_state.json")) +@pytest.fixture(name="gdc_zw062_state", scope="session") +def motorized_barrier_cover_state_fixture(): + """Load the motorized barrier cover node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_zw062_state.json")) + + +@pytest.fixture(name="iblinds_v2_state", scope="session") +def iblinds_v2_state_fixture(): + """Load the iBlinds v2 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_iblinds_v2_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.""" + return json.loads(load_fixture("zwave_js/aeon_smart_switch_6_state.json")) + + +@pytest.fixture(name="ge_12730_state", scope="session") +def ge_12730_state_fixture(): + """Load the GE 12730 node state fixture data.""" + return json.loads(load_fixture("zwave_js/fan_ge_12730_state.json")) + + +@pytest.fixture(name="aeotec_radiator_thermostat_state", scope="session") +def aeotec_radiator_thermostat_state_fixture(): + """Load the Aeotec Radiator Thermostat node state fixture data.""" + return json.loads(load_fixture("zwave_js/aeotec_radiator_thermostat_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state): """Mock a client.""" @@ -193,7 +317,7 @@ def mock_client_fixture(controller_state, version_state): @pytest.fixture(name="multisensor_6") def multisensor_6_fixture(client, multisensor_6_state): """Mock a multisensor 6 node.""" - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -201,7 +325,7 @@ def multisensor_6_fixture(client, multisensor_6_state): @pytest.fixture(name="ecolink_door_sensor") def legacy_binary_sensor_fixture(client, ecolink_door_sensor_state): """Mock a legacy_binary_sensor node.""" - node = Node(client, ecolink_door_sensor_state) + node = Node(client, copy.deepcopy(ecolink_door_sensor_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -209,7 +333,7 @@ def legacy_binary_sensor_fixture(client, ecolink_door_sensor_state): @pytest.fixture(name="hank_binary_switch") def hank_binary_switch_fixture(client, hank_binary_switch_state): """Mock a binary switch node.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -217,7 +341,7 @@ def hank_binary_switch_fixture(client, hank_binary_switch_state): @pytest.fixture(name="bulb_6_multi_color") def bulb_6_multi_color_fixture(client, bulb_6_multi_color_state): """Mock a bulb 6 multi-color node.""" - node = Node(client, bulb_6_multi_color_state) + node = Node(client, copy.deepcopy(bulb_6_multi_color_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -225,7 +349,7 @@ def bulb_6_multi_color_fixture(client, bulb_6_multi_color_state): @pytest.fixture(name="eaton_rf9640_dimmer") def eaton_rf9640_dimmer_fixture(client, eaton_rf9640_dimmer_state): """Mock a Eaton RF9640 (V4 compatible) dimmer node.""" - node = Node(client, eaton_rf9640_dimmer_state) + node = Node(client, copy.deepcopy(eaton_rf9640_dimmer_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -233,7 +357,7 @@ def eaton_rf9640_dimmer_fixture(client, eaton_rf9640_dimmer_state): @pytest.fixture(name="lock_schlage_be469") def lock_schlage_be469_fixture(client, lock_schlage_be469_state): """Mock a schlage lock node.""" - node = Node(client, lock_schlage_be469_state) + node = Node(client, copy.deepcopy(lock_schlage_be469_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -241,7 +365,7 @@ def lock_schlage_be469_fixture(client, lock_schlage_be469_state): @pytest.fixture(name="lock_august_pro") def lock_august_asl03_fixture(client, lock_august_asl03_state): """Mock a August Pro lock node.""" - node = Node(client, lock_august_asl03_state) + node = Node(client, copy.deepcopy(lock_august_asl03_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -251,7 +375,7 @@ def climate_radio_thermostat_ct100_plus_fixture( client, climate_radio_thermostat_ct100_plus_state ): """Mock a climate radio thermostat ct100 plus node.""" - node = Node(client, climate_radio_thermostat_ct100_plus_state) + node = Node(client, copy.deepcopy(climate_radio_thermostat_ct100_plus_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -261,7 +385,10 @@ def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( client, climate_radio_thermostat_ct100_plus_different_endpoints_state ): """Mock a climate radio thermostat ct100 plus node with values on different endpoints.""" - node = Node(client, climate_radio_thermostat_ct100_plus_different_endpoints_state) + node = Node( + client, + copy.deepcopy(climate_radio_thermostat_ct100_plus_different_endpoints_state), + ) client.driver.controller.nodes[node.node_id] = node return node @@ -269,7 +396,7 @@ def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( @pytest.fixture(name="climate_danfoss_lc_13") def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): """Mock a climate radio danfoss LC-13 node.""" - node = Node(client, climate_danfoss_lc_13_state) + node = Node(client, copy.deepcopy(climate_danfoss_lc_13_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -277,7 +404,7 @@ def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): @pytest.fixture(name="climate_heatit_z_trm3") def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state): """Mock a climate radio HEATIT Z-TRM3 node.""" - node = Node(client, climate_heatit_z_trm3_state) + node = Node(client, copy.deepcopy(climate_heatit_z_trm3_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -285,7 +412,15 @@ def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state): @pytest.fixture(name="nortek_thermostat") def nortek_thermostat_fixture(client, nortek_thermostat_state): """Mock a nortek thermostat node.""" - node = Node(client, nortek_thermostat_state) + node = Node(client, copy.deepcopy(nortek_thermostat_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="aeotec_radiator_thermostat") +def aeotec_radiator_thermostat_fixture(client, aeotec_radiator_thermostat_state): + """Mock a Aeotec thermostat node.""" + node = Node(client, aeotec_radiator_thermostat_state) client.driver.controller.nodes[node.node_id] = node return node @@ -322,7 +457,7 @@ async def integration_fixture(hass, client): @pytest.fixture(name="chain_actuator_zws12") def window_cover_fixture(client, chain_actuator_zws12_state): """Mock a window cover node.""" - node = Node(client, chain_actuator_zws12_state) + node = Node(client, copy.deepcopy(chain_actuator_zws12_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -330,7 +465,7 @@ def window_cover_fixture(client, chain_actuator_zws12_state): @pytest.fixture(name="in_wall_smart_fan_control") def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state): """Mock a fan node.""" - node = Node(client, in_wall_smart_fan_control_state) + node = Node(client, copy.deepcopy(in_wall_smart_fan_control_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -340,8 +475,40 @@ def multiple_devices_fixture( client, climate_radio_thermostat_ct100_plus_state, lock_schlage_be469_state ): """Mock a client with multiple devices.""" - node = Node(client, climate_radio_thermostat_ct100_plus_state) + node = Node(client, copy.deepcopy(climate_radio_thermostat_ct100_plus_state)) client.driver.controller.nodes[node.node_id] = node - node = Node(client, lock_schlage_be469_state) + node = Node(client, copy.deepcopy(lock_schlage_be469_state)) client.driver.controller.nodes[node.node_id] = node return client.driver.controller.nodes + + +@pytest.fixture(name="gdc_zw062") +def motorized_barrier_cover_fixture(client, gdc_zw062_state): + """Mock a motorized barrier node.""" + node = Node(client, copy.deepcopy(gdc_zw062_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="iblinds_v2") +def iblinds_cover_fixture(client, iblinds_v2_state): + """Mock an iBlinds v2.0 window cover node.""" + node = Node(client, copy.deepcopy(iblinds_v2_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.""" + node = Node(client, aeon_smart_switch_6_state) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="ge_12730") +def ge_12730_fixture(client, ge_12730_state): + """Mock a GE 12730 fan controller node.""" + node = Node(client, copy.deepcopy(ge_12730_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 88e8acc5771..dcbd924c86e 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,9 +1,26 @@ """Test the Z-Wave JS Websocket API.""" +import json from unittest.mock import patch +from zwave_js_server.const import LogLevel from zwave_js_server.event import Event +from zwave_js_server.exceptions import InvalidNewValue, NotFoundError, SetValueFailed -from homeassistant.components.zwave_js.api import ENTRY_ID, ID, NODE_ID, TYPE +from homeassistant.components.zwave_js.api import ( + CONFIG, + ENABLED, + ENTRY_ID, + FILENAME, + FORCE_CONSOLE, + ID, + LEVEL, + LOG_TO_FILE, + NODE_ID, + PROPERTY, + PROPERTY_KEY, + TYPE, + VALUE, +) from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.helpers.device_registry import async_get_registry @@ -40,6 +57,25 @@ async def test_websocket_api(hass, integration, multisensor_6, hass_ws_client): assert not result["is_secure"] assert result["status"] == 1 + # Test getting configuration parameter values + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/get_config_parameters", + ENTRY_ID: entry.entry_id, + NODE_ID: node.node_id, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert len(result) == 61 + key = "52-112-0-2-00-00" + assert result[key]["property"] == 2 + assert result[key]["metadata"]["type"] == "number" + assert result[key]["configuration_value_type"] == "enumerated" + assert result[key]["metadata"]["states"] + async def test_add_node( hass, integration, client, hass_ws_client, nortek_thermostat_added_event @@ -155,6 +191,125 @@ async def test_remove_node( assert device is None +async def test_set_config_parameter( + hass, client, hass_ws_client, multisensor_6, integration +): + """Test the set_config_parameter service.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"success": True} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/set_config_parameter", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + with patch( + "homeassistant.components.zwave_js.api.async_set_config_parameter", + ) as set_param_mock: + set_param_mock.side_effect = InvalidNewValue("test") + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/set_config_parameter", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + + assert len(client.async_send_command.call_args_list) == 0 + assert not msg["success"] + assert msg["error"]["code"] == "not_supported" + assert msg["error"]["message"] == "test" + + set_param_mock.side_effect = NotFoundError("test") + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/set_config_parameter", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + + assert len(client.async_send_command.call_args_list) == 0 + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert msg["error"]["message"] == "test" + + set_param_mock.side_effect = SetValueFailed("test") + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/set_config_parameter", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + + assert len(client.async_send_command.call_args_list) == 0 + assert not msg["success"] + assert msg["error"]["code"] == "unknown_error" + assert msg["error"]["message"] == "test" + + async def test_dump_view(integration, hass_client): """Test the HTTP dump view.""" client = await hass_client() @@ -164,7 +319,7 @@ async def test_dump_view(integration, hass_client): ): resp = await client.get(f"/api/zwave_js/dump/{integration.entry_id}") assert resp.status == 200 - assert await resp.text() == '{"hello": "world"}\n{"second": "msg"}\n' + assert json.loads(await resp.text()) == [{"hello": "world"}, {"second": "msg"}] async def test_dump_view_invalid_entry_id(integration, hass_client): @@ -172,3 +327,158 @@ async def test_dump_view_invalid_entry_id(integration, hass_client): client = await hass_client() resp = await client.get("/api/zwave_js/dump/INVALID") assert resp.status == 400 + + +async def test_update_log_config(hass, client, integration, hass_ws_client): + """Test that the update_log_config WS API call works and that schema validation works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Test we can set log level + client.async_send_command.return_value = {"success": True} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {LEVEL: "Error"}, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "update_log_config" + assert args["config"] == {"level": 0} + + client.async_send_command.reset_mock() + + # Test we can set logToFile to True + client.async_send_command.return_value = {"success": True} + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {LOG_TO_FILE: True, FILENAME: "/test"}, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "update_log_config" + assert args["config"] == {"logToFile": True, "filename": "/test"} + + client.async_send_command.reset_mock() + + # Test all parameters + client.async_send_command.return_value = {"success": True} + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: { + LEVEL: "Error", + LOG_TO_FILE: True, + FILENAME: "/test", + FORCE_CONSOLE: True, + ENABLED: True, + }, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "update_log_config" + assert args["config"] == { + "level": 0, + "logToFile": True, + "filename": "/test", + "forceConsole": True, + "enabled": True, + } + + client.async_send_command.reset_mock() + + # Test error when setting unrecognized log level + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {LEVEL: "bad_log_level"}, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert "error" in msg and "value must be one of" in msg["error"]["message"] + + # Test error without service data + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {}, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert "error" in msg and "must contain at least one of" in msg["error"]["message"] + + # Test error if we set logToFile to True without providing filename + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {LOG_TO_FILE: True}, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert ( + "error" in msg + and "must be provided if logging to file" in msg["error"]["message"] + ) + + +async def test_get_log_config(hass, client, integration, hass_ws_client): + """Test that the get_log_config WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Test we can get log configuration + client.async_send_command.return_value = { + "success": True, + "config": { + "enabled": True, + "level": 0, + "logToFile": False, + "filename": "/test.txt", + "forceConsole": False, + }, + } + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_log_config", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["result"] + assert msg["success"] + + log_config = msg["result"] + assert log_config["enabled"] + assert log_config["level"] == LogLevel.ERROR + assert log_config["log_to_file"] is False + assert log_config["filename"] == "/test.txt" + assert log_config["force_console"] is False diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index b2455f3cbbd..44804825885 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -5,6 +5,7 @@ from zwave_js_server.event import Event from homeassistant.components.climate.const import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, @@ -19,15 +20,19 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_NONE, + SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, ) +from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat_thermostat_mode" -CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat_heating" -CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat_thermostat_mode" +from .common import ( + CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_FLOOR_THERMOSTAT_ENTITY, + CLIMATE_RADIO_THERMOSTAT_ENTITY, +) async def test_thermostat_v2( @@ -50,6 +55,8 @@ async def test_thermostat_v2( assert state.attributes[ATTR_TEMPERATURE] == 22.2 assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert state.attributes[ATTR_FAN_MODE] == "Auto low" + assert state.attributes[ATTR_FAN_STATE] == "Idle / off" # Test setting preset mode await hass.services.async_call( @@ -62,8 +69,8 @@ async def test_thermostat_v2( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -85,7 +92,7 @@ async def test_thermostat_v2( } assert args["value"] == 1 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting hvac mode await hass.services.async_call( @@ -98,8 +105,8 @@ async def test_thermostat_v2( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -121,7 +128,7 @@ async def test_thermostat_v2( } assert args["value"] == 2 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting temperature await hass.services.async_call( @@ -135,8 +142,8 @@ async def test_thermostat_v2( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 2 + args = client.async_send_command_no_wait.call_args_list[0][0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -157,7 +164,7 @@ async def test_thermostat_v2( "value": 1, } assert args["value"] == 2 - args = client.async_send_command.call_args_list[1][0][0] + args = client.async_send_command_no_wait.call_args_list[1][0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -179,7 +186,7 @@ async def test_thermostat_v2( } assert args["value"] == 77 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test cool mode update from value updated event event = Event( @@ -230,7 +237,7 @@ async def test_thermostat_v2( assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 22.8 assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting temperature with heat_cool await hass.services.async_call( @@ -244,8 +251,8 @@ async def test_thermostat_v2( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 2 + args = client.async_send_command_no_wait.call_args_list[0][0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -267,7 +274,7 @@ async def test_thermostat_v2( } assert args["value"] == 77 - args = client.async_send_command.call_args_list[1][0][0] + args = client.async_send_command_no_wait.call_args_list[1][0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 13 assert args["valueId"] == { @@ -289,7 +296,7 @@ async def test_thermostat_v2( } assert args["value"] == 86 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() with pytest.raises(ValueError): # Test setting unknown preset mode @@ -303,7 +310,7 @@ async def test_thermostat_v2( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command_no_wait.call_args_list) == 0 # Test setting invalid hvac mode with pytest.raises(ValueError): @@ -329,6 +336,57 @@ async def test_thermostat_v2( blocking=True, ) + client.async_send_command_no_wait.reset_mock() + + # Test setting fan mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_FAN_MODE: "Low", + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "endpoint": 1, + "commandClass": 68, + "commandClassName": "Thermostat Fan Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 255, + "states": {"0": "Auto low", "1": "Low"}, + "label": "Thermostat fan mode", + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() + + # Test setting invalid fan mode + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_FAN_MODE: "fake value", + }, + blocking=True, + ) + async def test_thermostat_different_endpoints( hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration @@ -350,7 +408,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT] assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting temperature await hass.services.async_call( @@ -363,8 +421,8 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args_list[0][0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 5 assert args["valueId"] == { @@ -386,7 +444,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat } assert args["value"] == 21.5 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setpoint mode update from value updated event event = Event( @@ -400,7 +458,6 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat "commandClass": 67, "endpoint": 0, "property": "setpoint", - "propertyKey": 1, "propertyKeyName": "Heating", "propertyName": "setpoint", "newValue": 23, @@ -414,7 +471,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat assert state.state == HVAC_MODE_HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integration): diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 0270383174e..fc97f7420cf 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1,13 +1,13 @@ """Test the Z-Wave JS config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import DEFAULT, patch import pytest from zwave_js_server.version import VersionInfo from homeassistant import config_entries, setup from homeassistant.components.hassio.handler import HassioAPIError -from homeassistant.components.zwave_js.config_flow import TITLE +from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import DOMAIN from tests.common import MockConfigEntry @@ -22,86 +22,33 @@ ADDON_DISCOVERY_INFO = { @pytest.fixture(name="supervisor") def mock_supervisor_fixture(): """Mock Supervisor.""" - with patch("homeassistant.components.hassio.is_hassio", return_value=True): + with patch( + "homeassistant.components.zwave_js.config_flow.is_hassio", return_value=True + ): yield -@pytest.fixture(name="addon_info_side_effect") -def addon_info_side_effect_fixture(): - """Return the add-on info side effect.""" +@pytest.fixture(name="discovery_info") +def discovery_info_fixture(): + """Return the discovery info from the supervisor.""" + return DEFAULT + + +@pytest.fixture(name="discovery_info_side_effect") +def discovery_info_side_effect_fixture(): + """Return the discovery info from the supervisor.""" return None -@pytest.fixture(name="addon_info") -def mock_addon_info(addon_info_side_effect): - """Mock Supervisor add-on info.""" +@pytest.fixture(name="get_addon_discovery_info") +def mock_get_addon_discovery_info(discovery_info, discovery_info_side_effect): + """Mock get add-on discovery info.""" with patch( - "homeassistant.components.hassio.async_get_addon_info", - side_effect=addon_info_side_effect, - ) as addon_info: - addon_info.return_value = {} - yield addon_info - - -@pytest.fixture(name="addon_running") -def mock_addon_running(addon_info): - """Mock add-on already running.""" - addon_info.return_value["state"] = "started" - return addon_info - - -@pytest.fixture(name="addon_installed") -def mock_addon_installed(addon_info): - """Mock add-on already installed but not running.""" - addon_info.return_value["state"] = "stopped" - addon_info.return_value["version"] = "1.0" - return addon_info - - -@pytest.fixture(name="addon_options") -def mock_addon_options(addon_info): - """Mock add-on options.""" - addon_info.return_value["options"] = {} - return addon_info.return_value["options"] - - -@pytest.fixture(name="set_addon_options_side_effect") -def set_addon_options_side_effect_fixture(): - """Return the set add-on options side effect.""" - return None - - -@pytest.fixture(name="set_addon_options") -def mock_set_addon_options(set_addon_options_side_effect): - """Mock set add-on options.""" - with patch( - "homeassistant.components.hassio.async_set_addon_options", - side_effect=set_addon_options_side_effect, - ) as set_options: - yield set_options - - -@pytest.fixture(name="install_addon") -def mock_install_addon(): - """Mock install add-on.""" - with patch("homeassistant.components.hassio.async_install_addon") as install_addon: - yield install_addon - - -@pytest.fixture(name="start_addon_side_effect") -def start_addon_side_effect_fixture(): - """Return the set add-on options side effect.""" - return None - - -@pytest.fixture(name="start_addon") -def mock_start_addon(start_addon_side_effect): - """Mock start add-on.""" - with patch( - "homeassistant.components.hassio.async_start_addon", - side_effect=start_addon_side_effect, - ) as start_addon: - yield start_addon + "homeassistant.components.zwave_js.addon.async_get_addon_discovery_info", + side_effect=discovery_info_side_effect, + return_value=discovery_info, + ) as get_addon_discovery_info: + yield get_addon_discovery_info @pytest.fixture(name="server_version_side_effect") @@ -111,26 +58,37 @@ def server_version_side_effect_fixture(): @pytest.fixture(name="get_server_version", autouse=True) -def mock_get_server_version(server_version_side_effect): +def mock_get_server_version(server_version_side_effect, server_version_timeout): """Mock server version.""" version_info = VersionInfo( driver_version="mock-driver-version", server_version="mock-server-version", home_id=1234, + min_schema_version=0, + max_schema_version=1, ) with patch( "homeassistant.components.zwave_js.config_flow.get_server_version", side_effect=server_version_side_effect, return_value=version_info, - ) as mock_version: + ) as mock_version, patch( + "homeassistant.components.zwave_js.config_flow.SERVER_VERSION_TIMEOUT", + new=server_version_timeout, + ): yield mock_version +@pytest.fixture(name="server_version_timeout") +def mock_server_version_timeout(): + """Patch the timeout for getting server version.""" + return SERVER_VERSION_TIMEOUT + + @pytest.fixture(name="addon_setup_time", autouse=True) def mock_addon_setup_time(): """Mock add-on setup sleep time.""" with patch( - "homeassistant.components.zwave_js.config_flow.ADDON_SETUP_TIME", new=0 + "homeassistant.components.zwave_js.config_flow.ADDON_SETUP_TIMEOUT", new=0 ) as addon_setup_time: yield addon_setup_time @@ -171,22 +129,30 @@ async def test_manual(hass): assert result2["result"].unique_id == 1234 +async def slow_server_version(*args): + """Simulate a slow server version.""" + await asyncio.sleep(0.1) + + @pytest.mark.parametrize( - "url, server_version_side_effect, error", + "url, server_version_side_effect, server_version_timeout, error", [ ( "not-ws-url", None, + SERVER_VERSION_TIMEOUT, "invalid_ws_url", ), ( "ws://localhost:3000", - asyncio.TimeoutError, + slow_server_version, + 0, "cannot_connect", ), ( "ws://localhost:3000", Exception("Boom"), + SERVER_VERSION_TIMEOUT, "unknown", ), ], @@ -399,12 +365,47 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["step_id"] == "start_addon" assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 async def test_discovery_addon_not_installed( - hass, supervisor, addon_installed, install_addon, addon_options + hass, + supervisor, + addon_installed, + install_addon, + addon_options, + set_addon_options, + start_addon, ): """Test discovery with add-on not installed.""" addon_installed.return_value["version"] = None @@ -429,8 +430,37 @@ async def test_discovery_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "progress" assert result["step_id"] == "start_addon" + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_not_addon(hass, supervisor): """Test opting out of add-on on Supervisor.""" @@ -477,6 +507,49 @@ async def test_not_addon(hass, supervisor): assert len(mock_setup_entry.mock_calls) == 1 +async def test_addon_already_configured(hass, supervisor): + """Test add-on already configured leads to manual step.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"use_addon": True}, title=TITLE, unique_id=5678 + ) + entry.add_to_hass(hass) + + 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"] == "form" + assert result["step_id"] == "manual" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://localhost:3000", + "usb_path": None, + "network_key": None, + "use_addon": False, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running( hass, @@ -544,7 +617,7 @@ async def test_addon_running( None, None, None, - "addon_missing_discovery_info", + "addon_get_discovery_info_failed", ), ( {"config": ADDON_DISCOVERY_INFO}, @@ -559,10 +632,13 @@ async def test_addon_running_failures( hass, supervisor, addon_running, + addon_options, get_addon_discovery_info, abort_reason, ): """Test all failures when add-on is running.""" + addon_options["device"] = "/test" + addon_options["network_key"] = "abc123" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -582,9 +658,11 @@ async def test_addon_running_failures( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running_already_configured( - hass, supervisor, addon_running, get_addon_discovery_info + hass, supervisor, addon_running, addon_options, get_addon_discovery_info ): """Test that only one unique instance is allowed when add-on is running.""" + addon_options["device"] = "/test" + addon_options["network_key"] = "abc123" entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) entry.add_to_hass(hass) @@ -629,6 +707,13 @@ async def test_addon_installed( ) assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "progress" assert result["step_id"] == "start_addon" with patch( @@ -637,9 +722,8 @@ async def test_addon_installed( "homeassistant.components.zwave_js.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} - ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] == "create_entry" @@ -683,40 +767,32 @@ async def test_addon_installed_start_failure( ) assert result["type"] == "form" - assert result["step_id"] == "start_addon" + assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} ) - assert result["type"] == "form" - assert result["errors"] == {"base": "addon_start_failed"} + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "abort" + assert result["reason"] == "addon_start_failed" @pytest.mark.parametrize( - "set_addon_options_side_effect, start_addon_side_effect, discovery_info, " - "server_version_side_effect, abort_reason", + "discovery_info, server_version_side_effect", [ ( - HassioAPIError(), - None, - {"config": ADDON_DISCOVERY_INFO}, - None, - "addon_set_config_failed", - ), - ( - None, - None, {"config": ADDON_DISCOVERY_INFO}, asyncio.TimeoutError, - "cannot_connect", ), ( None, None, - None, - None, - "addon_missing_discovery_info", ), ], ) @@ -728,7 +804,6 @@ async def test_addon_installed_failures( set_addon_options, start_addon, get_addon_discovery_info, - abort_reason, ): """Test all failures when add-on is installed.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -745,14 +820,58 @@ async def test_addon_installed_failures( ) assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "progress" assert result["step_id"] == "start_addon" + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "abort" + assert result["reason"] == "addon_start_failed" + + +@pytest.mark.parametrize( + "set_addon_options_side_effect, discovery_info", + [(HassioAPIError(), {"config": ADDON_DISCOVERY_INFO})], +) +async def test_addon_installed_set_options_failure( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test all failures when add-on is installed.""" + 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"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} ) assert result["type"] == "abort" - assert result["reason"] == abort_reason + assert result["reason"] == "addon_set_config_failed" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -782,12 +901,18 @@ async def test_addon_installed_already_configured( ) assert result["type"] == "form" - assert result["step_id"] == "start_addon" + assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} ) + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -819,6 +944,7 @@ async def test_addon_not_installed( ) assert result["type"] == "progress" + assert result["step_id"] == "install_addon" # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() @@ -826,6 +952,13 @@ async def test_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "progress" assert result["step_id"] == "start_addon" with patch( @@ -834,9 +967,8 @@ async def test_addon_not_installed( "homeassistant.components.zwave_js.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} - ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] == "create_entry" diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 52e0a444ec9..e6118f9b37d 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,13 +1,28 @@ """Test the Z-Wave JS cover platform.""" from zwave_js_server.event import Event -from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + DEVICE_CLASS_GARAGE, + DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, +) -WINDOW_COVER_ENTITY = "cover.zws_12_current_value" +WINDOW_COVER_ENTITY = "cover.zws_12" +GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" -async def test_cover(hass, client, chain_actuator_zws12, integration): - """Test the light entity.""" +async def test_window_cover(hass, client, chain_actuator_zws12, integration): + """Test the cover entity.""" node = chain_actuator_zws12 state = hass.states.get(WINDOW_COVER_ENTITY) @@ -23,8 +38,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 6 assert args["valueId"] == { @@ -45,7 +60,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert args["value"] == 50 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting position await hass.services.async_call( @@ -55,8 +70,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 6 assert args["valueId"] == { @@ -77,7 +92,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert args["value"] == 0 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test opening await hass.services.async_call( @@ -87,8 +102,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 6 assert args["valueId"] == { @@ -109,7 +124,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert args["value"] - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test stop after opening await hass.services.async_call( "cover", @@ -118,8 +133,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 - open_args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 2 + open_args = client.async_send_command_no_wait.call_args_list[0][0][0] assert open_args["command"] == "node.set_value" assert open_args["nodeId"] == 6 assert open_args["valueId"] == { @@ -138,7 +153,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert not open_args["value"] - close_args = client.async_send_command.call_args_list[1][0][0] + close_args = client.async_send_command_no_wait.call_args_list[1][0][0] assert close_args["command"] == "node.set_value" assert close_args["nodeId"] == 6 assert close_args["valueId"] == { @@ -176,7 +191,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): }, ) node.receive_event(event) - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() state = hass.states.get(WINDOW_COVER_ENTITY) assert state.state == "open" @@ -188,8 +203,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): {"entity_id": WINDOW_COVER_ENTITY}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 6 assert args["valueId"] == { @@ -210,7 +225,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert args["value"] == 0 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test stop after closing await hass.services.async_call( @@ -220,8 +235,8 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 - open_args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 2 + open_args = client.async_send_command_no_wait.call_args_list[0][0][0] assert open_args["command"] == "node.set_value" assert open_args["nodeId"] == 6 assert open_args["valueId"] == { @@ -240,7 +255,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert not open_args["value"] - close_args = client.async_send_command.call_args_list[1][0][0] + close_args = client.async_send_command_no_wait.call_args_list[1][0][0] assert close_args["command"] == "node.set_value" assert close_args["nodeId"] == 6 assert close_args["valueId"] == { @@ -259,7 +274,7 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): } assert not close_args["value"] - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() event = Event( type="value updated", @@ -282,3 +297,197 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): state = hass.states.get(WINDOW_COVER_ENTITY) assert state.state == "closed" + + +async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): + """Test the cover entity.""" + node = gdc_zw062 + + state = hass.states.get(GDC_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_GARAGE + + assert state.state == STATE_CLOSED + + # Test open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["value"] == 255 + assert args["valueId"] == { + "ccVersion": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "endpoint": 0, + "metadata": { + "label": "Target Barrier State", + "max": 255, + "min": 0, + "readable": True, + "states": {"0": "Closed", "255": "Open"}, + "type": "number", + "writeable": True, + }, + "property": "targetState", + "propertyName": "targetState", + } + + # state doesn't change until currentState value update is received + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_CLOSED + + client.async_send_command_no_wait.reset_mock() + + # Test close + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["value"] == 0 + assert args["valueId"] == { + "ccVersion": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "endpoint": 0, + "metadata": { + "label": "Target Barrier State", + "max": 255, + "min": 0, + "readable": True, + "states": {"0": "Closed", "255": "Open"}, + "type": "number", + "writeable": True, + }, + "property": "targetState", + "propertyName": "targetState", + } + + # state doesn't change until currentState value update is received + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_CLOSED + + client.async_send_command_no_wait.reset_mock() + + # Barrier sends an opening state + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "currentState", + "newValue": 254, + "prevValue": 0, + "propertyName": "currentState", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_OPENING + + # Barrier sends an opened state + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "currentState", + "newValue": 255, + "prevValue": 254, + "propertyName": "currentState", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_OPEN + + # Barrier sends a closing state + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "currentState", + "newValue": 252, + "prevValue": 255, + "propertyName": "currentState", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_CLOSING + + # Barrier sends a closed state + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "currentState", + "newValue": 0, + "prevValue": 252, + "propertyName": "currentState", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_CLOSED + + # Barrier sends a stopped state + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "currentState", + "newValue": 253, + "prevValue": 252, + "propertyName": "currentState", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(GDC_COVER_ENTITY) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py new file mode 100644 index 00000000000..e28c8ae1563 --- /dev/null +++ b/tests/components/zwave_js/test_discovery.py @@ -0,0 +1,25 @@ +"""Test discovery of entities for device-specific schemas for the Z-Wave JS integration.""" + + +async def test_iblinds_v2(hass, client, iblinds_v2, integration): + """Test that an iBlinds v2.0 multilevel switch value is discovered as a cover.""" + node = iblinds_v2 + assert node.device_class.specific.label == "Unused" + + state = hass.states.get("light.window_blind_controller") + assert not state + + state = hass.states.get("cover.window_blind_controller") + assert state + + +async def test_ge_12730(hass, client, ge_12730, integration): + """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" + node = ge_12730 + assert node.device_class.specific.label == "Multilevel Power Switch" + + state = hass.states.get("light.in_wall_smart_fan_control") + assert not state + + state = hass.states.get("fan.in_wall_smart_fan_control") + assert state diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 2a347f6afea..e40782270a9 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -47,6 +47,7 @@ async def test_scenes(hass, hank_binary_switch, integration, client): assert events[0].data["command_class_name"] == "Basic" assert events[0].data["label"] == "Event value" assert events[0].data["value"] == 255 + assert events[0].data["value_raw"] == 255 # Publish fake Scene Activation value notification event = Event( @@ -82,6 +83,7 @@ async def test_scenes(hass, hank_binary_switch, integration, client): assert events[1].data["command_class_name"] == "Scene Activation" assert events[1].data["label"] == "Scene ID" assert events[1].data["value"] == 16 + assert events[1].data["value_raw"] == 16 # Publish fake Central Scene value notification event = Event( @@ -128,6 +130,7 @@ async def test_scenes(hass, hank_binary_switch, integration, client): assert events[2].data["command_class_name"] == "Central Scene" assert events[2].data["label"] == "Scene 001" assert events[2].data["value"] == "KeyPressed3x" + assert events[2].data["value_raw"] == 4 async def test_notifications(hass, hank_binary_switch, integration, client): diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 5b726179ac9..0ee007aab35 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -4,7 +4,7 @@ from zwave_js_server.event import Event from homeassistant.components.fan import ATTR_SPEED, SPEED_MEDIUM -FAN_ENTITY = "fan.in_wall_smart_fan_control_current_value" +FAN_ENTITY = "fan.in_wall_smart_fan_control" async def test_fan(hass, client, in_wall_smart_fan_control, integration): @@ -23,8 +23,8 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 17 assert args["valueId"] == { @@ -43,9 +43,9 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): "label": "Target value", }, } - assert args["value"] == 50 + assert args["value"] == 66 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test setting unknown speed with pytest.raises(ValueError): @@ -56,7 +56,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): blocking=True, ) - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turn on no speed await hass.services.async_call( @@ -66,8 +66,8 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 17 assert args["valueId"] == { @@ -88,7 +88,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): } assert args["value"] == 255 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning off await hass.services.async_call( @@ -98,8 +98,8 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 17 assert args["valueId"] == { @@ -120,7 +120,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): } assert args["value"] == 0 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test speed update from value updated event event = Event( @@ -146,7 +146,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): assert state.state == "on" assert state.attributes[ATTR_SPEED] == "high" - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() event = Event( type="value updated", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1aad07400ad..6f60bbc0300 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,15 +1,17 @@ """Test the Z-Wave JS init module.""" from copy import deepcopy -from unittest.mock import patch +from unittest.mock import call, patch import pytest +from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zwave_js.const import DOMAIN -from homeassistant.components.zwave_js.entity import get_device_id +from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ( CONN_CLASS_LOCAL_PUSH, + DISABLED_USER, ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED, ENTRY_STATE_SETUP_RETRY, @@ -17,7 +19,11 @@ from homeassistant.config_entries import ( from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import device_registry, entity_registry -from .common import AIR_TEMPERATURE_SENSOR +from .common import ( + AIR_TEMPERATURE_SENSOR, + EATON_RF9640_ENTITY, + NOTIFICATION_MOTION_BINARY_SENSOR, +) from tests.common import MockConfigEntry @@ -29,22 +35,6 @@ def connect_timeout_fixture(): yield timeout -@pytest.fixture(name="stop_addon") -def stop_addon_fixture(): - """Mock stop add-on.""" - with patch("homeassistant.components.hassio.async_stop_addon") as stop_addon: - yield stop_addon - - -@pytest.fixture(name="uninstall_addon") -def uninstall_addon_fixture(): - """Mock uninstall add-on.""" - with patch( - "homeassistant.components.hassio.async_uninstall_addon" - ) as uninstall_addon: - yield uninstall_addon - - async def test_entry_setup_unload(hass, client, integration): """Test the integration set up and unload.""" entry = integration @@ -76,6 +66,26 @@ async def test_initialized_timeout(hass, client, connect_timeout): assert entry.state == ENTRY_STATE_SETUP_RETRY +@pytest.mark.parametrize("error", [BaseZwaveJSServerError("Boom"), Exception("Boom")]) +async def test_listen_failure(hass, client, error): + """Test we handle errors during client listen.""" + + async def listen(driver_ready): + """Mock the client listen method.""" + # Set the connect side effect to stop an endless loop on reload. + client.connect.side_effect = BaseZwaveJSServerError("Boom") + raise error + + client.listen.side_effect = listen + 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() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + + async def test_on_node_added_ready( hass, multisensor_6_state, client, integration, device_registry ): @@ -103,6 +113,159 @@ async def test_on_node_added_ready( ) +async def test_unique_id_migration_dupes( + hass, multisensor_6_state, client, integration +): + """Test we remove an entity when .""" + ent_reg = entity_registry.async_get(hass) + + entity_name = AIR_TEMPERATURE_SENSOR.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id_1 = ( + f"{client.driver.controller.home_id}.52.52-49-00-Air temperature-00" + ) + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id_1, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == AIR_TEMPERATURE_SENSOR + assert entity_entry.unique_id == old_unique_id_1 + + # Create entity RegistryEntry using b0 unique ID format + old_unique_id_2 = ( + f"{client.driver.controller.home_id}.52.52-49-0-Air temperature-00-00" + ) + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id_2, + suggested_object_id=f"{entity_name}_1", + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == f"{AIR_TEMPERATURE_SENSOR}_1" + assert entity_entry.unique_id == old_unique_id_2 + + # Add a ready node, unique ID should be migrated + node = Node(client, multisensor_6_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00" + assert entity_entry.unique_id == new_unique_id + + assert ent_reg.async_get(f"{AIR_TEMPERATURE_SENSOR}_1") is None + + +async def test_unique_id_migration_v1(hass, multisensor_6_state, client, integration): + """Test unique ID is migrated from old format to new (version 1).""" + ent_reg = entity_registry.async_get(hass) + + # Migrate version 1 + entity_name = AIR_TEMPERATURE_SENSOR.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.52.52-49-00-Air temperature-00" + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == AIR_TEMPERATURE_SENSOR + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, multisensor_6_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_v2(hass, multisensor_6_state, client, integration): + """Test unique ID is migrated from old format to new (version 2).""" + ent_reg = entity_registry.async_get(hass) + # Migrate version 2 + ILLUMINANCE_SENSOR = "sensor.multisensor_6_illuminance" + entity_name = ILLUMINANCE_SENSOR.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.52.52-49-0-Illuminance-00-00" + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == ILLUMINANCE_SENSOR + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, multisensor_6_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR) + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_notification_binary_sensor( + hass, multisensor_6_state, client, integration +): + """Test unique ID is migrated from old format to new for a notification binary sensor.""" + ent_reg = entity_registry.async_get(hass) + + entity_name = NOTIFICATION_MOTION_BINARY_SENSOR.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.52.52-113-00-Home Security-Motion sensor status.8" + entity_entry = ent_reg.async_get_or_create( + "binary_sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == NOTIFICATION_MOTION_BINARY_SENSOR + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, multisensor_6_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) + new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status-Motion sensor status.8" + assert entity_entry.unique_id == new_unique_id + + async def test_on_node_added_not_ready( hass, multisensor_6_state, client, integration, device_registry ): @@ -189,7 +352,203 @@ async def test_existing_node_not_ready(hass, client, multisensor_6, device_regis ) -async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): +async def test_start_addon( + hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon +): + """Test start the Z-Wave JS add-on during entry setup.""" + device = "/test" + network_key = "abc123" + addon_options = { + "device": device, + "network_key": network_key, + } + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 0 + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": addon_options} + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call(hass, "core_zwave_js") + + +async def test_install_addon( + hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon +): + """Test install and start the Z-Wave JS add-on during entry setup.""" + addon_installed.return_value["version"] = None + device = "/test" + network_key = "abc123" + addon_options = { + "device": device, + "network_key": network_key, + } + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 1 + assert install_addon.call_args == call(hass, "core_zwave_js") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": addon_options} + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call(hass, "core_zwave_js") + + +@pytest.mark.parametrize("addon_info_side_effect", [HassioAPIError("Boom")]) +async def test_addon_info_failure( + hass, + addon_installed, + install_addon, + addon_options, + set_addon_options, + start_addon, +): + """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 0 + assert start_addon.call_count == 0 + + +@pytest.mark.parametrize( + "addon_version, update_available, update_calls, update_addon_side_effect", + [ + ("1.0", True, 1, None), + ("1.0", False, 0, None), + ("1.0", True, 1, HassioAPIError("Boom")), + ], +) +async def test_update_addon( + hass, + client, + addon_info, + addon_installed, + addon_running, + create_shapshot, + update_addon, + addon_options, + addon_version, + update_available, + update_calls, + update_addon_side_effect, +): + """Test update the Z-Wave JS add-on during entry setup.""" + addon_info.return_value["version"] = addon_version + addon_info.return_value["update_available"] = update_available + update_addon.side_effect = update_addon_side_effect + client.connect.side_effect = InvalidServerVersion("Invalid version") + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={ + "url": "ws://host1:3001", + "use_addon": True, + "usb_path": device, + "network_key": network_key, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": f"addon_core_zwave_js_{addon_version}", "addons": ["core_zwave_js"]}, + partial=True, + ) + assert update_addon.call_count == update_calls + + +@pytest.mark.parametrize( + "stop_addon_side_effect, entry_state", + [ + (None, ENTRY_STATE_NOT_LOADED), + (HassioAPIError("Boom"), ENTRY_STATE_LOADED), + ], +) +async def test_stop_addon( + hass, + client, + addon_installed, + addon_running, + addon_options, + stop_addon, + stop_addon_side_effect, + entry_state, +): + """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" + stop_addon.side_effect = stop_addon_side_effect + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={ + "url": "ws://host1:3001", + "use_addon": True, + "usb_path": device, + "network_key": network_key, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_set_disabled_by(entry.entry_id, DISABLED_USER) + await hass.async_block_till_done() + + assert entry.state == entry_state + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + + +async def test_remove_entry( + hass, addon_installed, stop_addon, create_shapshot, uninstall_addon, caplog +): """Test remove the config entry.""" # test successful remove without created add-on entry = MockConfigEntry( @@ -220,10 +579,19 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) assert uninstall_addon.call_count == 1 + assert uninstall_addon.call_args == call(hass, "core_zwave_js") assert entry.state == ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure @@ -234,12 +602,39 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state == ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the Z-Wave JS add-on" in caplog.text stop_addon.side_effect = None stop_addon.reset_mock() + create_shapshot.reset_mock() + uninstall_addon.reset_mock() + + # test create snapshot failure + entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + create_shapshot.side_effect = HassioAPIError() + + await hass.config_entries.async_remove(entry.entry_id) + + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) + assert uninstall_addon.call_count == 0 + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert "Failed to create a snapshot of the Z-Wave JS add-on" in caplog.text + create_shapshot.side_effect = None + stop_addon.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure @@ -250,7 +645,15 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) assert uninstall_addon.call_count == 1 + assert uninstall_addon.call_args == call(hass, "core_zwave_js") assert entry.state == ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text @@ -293,3 +696,17 @@ async def test_removed_device(hass, client, multiple_devices, integration): ) assert len(entity_entries) == 15 assert dev_reg.async_get_device({get_device_id(client, old_node)}) is None + + +async def test_suggested_area(hass, client, eaton_rf9640_dimmer): + """Test that suggested area works.""" + dev_reg = device_registry.async_get(hass) + ent_reg = entity_registry.async_get(hass) + + 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() + + entity = ent_reg.async_get(EATON_RF9640_ENTITY) + assert dev_reg.async_get(entity.device_id).area_id is not None diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index b60c7281874..c16e2474980 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -12,8 +12,11 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON -BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color_current_value" -EATON_RF9640_ENTITY = "light.allloaddimmer_current_value" +from .common import ( + AEON_SMART_SWITCH_LIGHT_ENTITY, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + EATON_RF9640_ENTITY, +) async def test_light(hass, client, bulb_6_multi_color, integration): @@ -35,8 +38,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 39 assert args["valueId"] == { @@ -57,7 +60,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): } assert args["value"] == 255 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test brightness update from value updated event event = Event( @@ -92,9 +95,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command_no_wait.call_args_list) == 1 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning on with brightness await hass.services.async_call( @@ -104,8 +107,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 39 assert args["valueId"] == { @@ -126,7 +129,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): } assert args["value"] == 50 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning on with rgb color await hass.services.async_call( @@ -136,58 +139,62 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 5 - warm_args = client.async_send_command.call_args_list[0][0][0] # warm white 0 + assert len(client.async_send_command_no_wait.call_args_list) == 6 + warm_args = client.async_send_command_no_wait.call_args_list[0][0][0] # red 255 assert warm_args["command"] == "node.set_value" assert warm_args["nodeId"] == 39 assert warm_args["valueId"]["commandClassName"] == "Color Switch" assert warm_args["valueId"]["commandClass"] == 51 assert warm_args["valueId"]["endpoint"] == 0 - assert warm_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" + assert warm_args["valueId"]["metadata"]["label"] == "Target value (Red)" assert warm_args["valueId"]["property"] == "targetColor" assert warm_args["valueId"]["propertyName"] == "targetColor" - assert warm_args["value"] == 0 + assert warm_args["value"] == 255 - cold_args = client.async_send_command.call_args_list[1][0][0] # cold white 0 + cold_args = client.async_send_command_no_wait.call_args_list[1][0][0] # green 76 assert cold_args["command"] == "node.set_value" assert cold_args["nodeId"] == 39 assert cold_args["valueId"]["commandClassName"] == "Color Switch" assert cold_args["valueId"]["commandClass"] == 51 assert cold_args["valueId"]["endpoint"] == 0 - assert cold_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" + assert cold_args["valueId"]["metadata"]["label"] == "Target value (Green)" assert cold_args["valueId"]["property"] == "targetColor" assert cold_args["valueId"]["propertyName"] == "targetColor" - assert cold_args["value"] == 0 - red_args = client.async_send_command.call_args_list[2][0][0] # red 255 + assert cold_args["value"] == 76 + red_args = client.async_send_command_no_wait.call_args_list[2][0][0] # blue 255 assert red_args["command"] == "node.set_value" assert red_args["nodeId"] == 39 assert red_args["valueId"]["commandClassName"] == "Color Switch" assert red_args["valueId"]["commandClass"] == 51 assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Red)" + assert red_args["valueId"]["metadata"]["label"] == "Target value (Blue)" assert red_args["valueId"]["property"] == "targetColor" assert red_args["valueId"]["propertyName"] == "targetColor" assert red_args["value"] == 255 - green_args = client.async_send_command.call_args_list[3][0][0] # green 76 + green_args = client.async_send_command_no_wait.call_args_list[3][0][ + 0 + ] # warm white 0 assert green_args["command"] == "node.set_value" assert green_args["nodeId"] == 39 assert green_args["valueId"]["commandClassName"] == "Color Switch" assert green_args["valueId"]["commandClass"] == 51 assert green_args["valueId"]["endpoint"] == 0 - assert green_args["valueId"]["metadata"]["label"] == "Target value (Green)" + assert green_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" assert green_args["valueId"]["property"] == "targetColor" assert green_args["valueId"]["propertyName"] == "targetColor" - assert green_args["value"] == 76 - blue_args = client.async_send_command.call_args_list[4][0][0] # blue 255 + assert green_args["value"] == 0 + blue_args = client.async_send_command_no_wait.call_args_list[4][0][ + 0 + ] # cold white 0 assert blue_args["command"] == "node.set_value" assert blue_args["nodeId"] == 39 assert blue_args["valueId"]["commandClassName"] == "Color Switch" assert blue_args["valueId"]["commandClass"] == 51 assert blue_args["valueId"]["endpoint"] == 0 - assert blue_args["valueId"]["metadata"]["label"] == "Target value (Blue)" + assert blue_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" assert blue_args["valueId"]["property"] == "targetColor" assert blue_args["valueId"]["propertyName"] == "targetColor" - assert blue_args["value"] == 255 + assert blue_args["value"] == 0 # Test rgb color update from value updated event red_event = Event( @@ -203,17 +210,21 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "property": "currentColor", "newValue": 255, "prevValue": 0, + "propertyKey": 2, "propertyKeyName": "Red", }, }, ) green_event = deepcopy(red_event) - green_event.data["args"].update({"newValue": 76, "propertyKeyName": "Green"}) + green_event.data["args"].update( + {"newValue": 76, "propertyKey": 3, "propertyKeyName": "Green"} + ) blue_event = deepcopy(red_event) + blue_event.data["args"]["propertyKey"] = 4 blue_event.data["args"]["propertyKeyName"] = "Blue" warm_white_event = deepcopy(red_event) warm_white_event.data["args"].update( - {"newValue": 0, "propertyKeyName": "Warm White"} + {"newValue": 0, "propertyKey": 0, "propertyKeyName": "Warm White"} ) node.receive_event(warm_white_event) node.receive_event(red_event) @@ -223,10 +234,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert state.attributes[ATTR_COLOR_TEMP] == 370 assert state.attributes[ATTR_RGB_COLOR] == (255, 76, 255) - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning on with same rgb color await hass.services.async_call( @@ -236,9 +246,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 5 + assert len(client.async_send_command_no_wait.call_args_list) == 6 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning on with color temp await hass.services.async_call( @@ -248,8 +258,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 5 - red_args = client.async_send_command.call_args_list[0][0][0] # red 0 + assert len(client.async_send_command_no_wait.call_args_list) == 6 + red_args = client.async_send_command_no_wait.call_args_list[0][0][0] # red 0 assert red_args["command"] == "node.set_value" assert red_args["nodeId"] == 39 assert red_args["valueId"]["commandClassName"] == "Color Switch" @@ -259,7 +269,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert red_args["valueId"]["property"] == "targetColor" assert red_args["valueId"]["propertyName"] == "targetColor" assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[1][0][0] # green 0 + red_args = client.async_send_command_no_wait.call_args_list[1][0][0] # green 0 assert red_args["command"] == "node.set_value" assert red_args["nodeId"] == 39 assert red_args["valueId"]["commandClassName"] == "Color Switch" @@ -269,7 +279,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert red_args["valueId"]["property"] == "targetColor" assert red_args["valueId"]["propertyName"] == "targetColor" assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[2][0][0] # blue 0 + red_args = client.async_send_command_no_wait.call_args_list[2][0][0] # blue 0 assert red_args["command"] == "node.set_value" assert red_args["nodeId"] == 39 assert red_args["valueId"]["commandClassName"] == "Color Switch" @@ -279,7 +289,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert red_args["valueId"]["property"] == "targetColor" assert red_args["valueId"]["propertyName"] == "targetColor" assert red_args["value"] == 0 - warm_args = client.async_send_command.call_args_list[3][0][0] # warm white 0 + warm_args = client.async_send_command_no_wait.call_args_list[3][0][ + 0 + ] # warm white 0 assert warm_args["command"] == "node.set_value" assert warm_args["nodeId"] == 39 assert warm_args["valueId"]["commandClassName"] == "Color Switch" @@ -289,7 +301,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert warm_args["valueId"]["property"] == "targetColor" assert warm_args["valueId"]["propertyName"] == "targetColor" assert warm_args["value"] == 20 - red_args = client.async_send_command.call_args_list[4][0][0] # cold white + red_args = client.async_send_command_no_wait.call_args_list[4][0][0] # cold white assert red_args["command"] == "node.set_value" assert red_args["nodeId"] == 39 assert red_args["valueId"]["commandClassName"] == "Color Switch" @@ -300,7 +312,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert red_args["valueId"]["propertyName"] == "targetColor" assert red_args["value"] == 235 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test color temp update from value updated event red_event = Event( @@ -316,23 +328,25 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "property": "currentColor", "newValue": 0, "prevValue": 255, + "propertyKey": 2, "propertyKeyName": "Red", }, }, ) green_event = deepcopy(red_event) green_event.data["args"].update( - {"newValue": 0, "prevValue": 76, "propertyKeyName": "Green"} + {"newValue": 0, "prevValue": 76, "propertyKey": 3, "propertyKeyName": "Green"} ) blue_event = deepcopy(red_event) + blue_event.data["args"]["propertyKey"] = 4 blue_event.data["args"]["propertyKeyName"] = "Blue" warm_white_event = deepcopy(red_event) warm_white_event.data["args"].update( - {"newValue": 20, "propertyKeyName": "Warm White"} + {"newValue": 20, "propertyKey": 0, "propertyKeyName": "Warm White"} ) cold_white_event = deepcopy(red_event) cold_white_event.data["args"].update( - {"newValue": 235, "propertyKeyName": "Cold White"} + {"newValue": 235, "propertyKey": 1, "propertyKeyName": "Cold White"} ) node.receive_event(red_event) node.receive_event(green_event) @@ -354,9 +368,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 5 + assert len(client.async_send_command_no_wait.call_args_list) == 6 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test turning off await hass.services.async_call( @@ -366,8 +380,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 39 assert args["valueId"] == { @@ -397,3 +411,9 @@ async def test_v4_dimmer_light(hass, client, eaton_rf9640_dimmer, integration): assert state.state == STATE_ON # the light should pick currentvalue which has zwave value 22 assert state.attributes[ATTR_BRIGHTNESS] == 57 + + +async def test_optional_light(hass, client, aeon_smart_switch_6, integration): + """Test a device that has an additional light endpoint being identified as light.""" + state = hass.states.get(AEON_SMART_SWITCH_LIGHT_ENTITY) + assert state.state == STATE_ON diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 069b3497a55..e4032cf42ed 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -14,7 +14,7 @@ from homeassistant.components.zwave_js.lock import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED -SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt_current_lock_mode" +SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt" async def test_door_lock(hass, client, lock_schlage_be469, integration): @@ -33,8 +33,8 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 20 assert args["valueId"] == { @@ -64,7 +64,7 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): } assert args["value"] == 255 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test locked update from value updated event event = Event( @@ -88,7 +88,7 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): assert hass.states.get(SCHLAGE_BE469_LOCK_ENTITY).state == STATE_LOCKED - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test unlocking await hass.services.async_call( @@ -98,8 +98,8 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 20 assert args["valueId"] == { @@ -129,7 +129,7 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): } assert args["value"] == 0 - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test set usercode service await hass.services.async_call( @@ -143,8 +143,8 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 20 assert args["valueId"] == { @@ -167,7 +167,7 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): } assert args["value"] == "1234" - client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() # Test clear usercode await hass.services.async_call( @@ -177,8 +177,8 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 20 assert args["valueId"] == { diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py new file mode 100644 index 00000000000..136e62c5405 --- /dev/null +++ b/tests/components/zwave_js/test_number.py @@ -0,0 +1,69 @@ +"""Test the Z-Wave JS number platform.""" +from zwave_js_server.event import Event + +NUMBER_ENTITY = "number.thermostat_hvac_valve_control" + + +async def test_number(hass, client, aeotec_radiator_thermostat, integration): + """Test the number entity.""" + node = aeotec_radiator_thermostat + state = hass.states.get(NUMBER_ENTITY) + + assert state + assert state.state == "75.0" + + # Test turn on setting value + await hass.services.async_call( + "number", + "set_value", + {"entity_id": NUMBER_ENTITY, "value": 30}, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 4 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "ccVersion": 1, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + }, + } + assert args["value"] == 30.0 + + client.async_send_command_no_wait.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(NUMBER_ENTITY) + assert state.state == "99.0" diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py new file mode 100644 index 00000000000..d0bf08c1b7a --- /dev/null +++ b/tests/components/zwave_js/test_services.py @@ -0,0 +1,364 @@ +"""Test the Z-Wave JS services.""" +import pytest +import voluptuous as vol + +from homeassistant.components.zwave_js.const import ( + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_CONFIG_VALUE, + ATTR_REFRESH_ALL_VALUES, + DOMAIN, + SERVICE_REFRESH_VALUE, + SERVICE_SET_CONFIG_PARAMETER, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg + +from .common import AIR_TEMPERATURE_SENSOR, CLIMATE_RADIO_THERMOSTAT_ENTITY + +from tests.common import MockConfigEntry + + +async def test_set_config_parameter(hass, client, multisensor_6, integration): + """Test the set_config_parameter service.""" + dev_reg = async_get_dev_reg(hass) + ent_reg = async_get_ent_reg(hass) + entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + + # Test setting config parameter by property and property_key + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting parameter by property name + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: "Group 2: Send battery reports", + ATTR_CONFIG_VALUE: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting parameter by property name and state label + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: entity_entry.device_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 41, + "propertyName": "Temperature Threshold (Unit)", + "propertyKey": 15, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 3, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": False, + "states": {"1": "Celsius", "2": "Fahrenheit"}, + "label": "Temperature Threshold (Unit)", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test setting parameter by property and bitmask + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: "0x01", + ATTR_CONFIG_VALUE: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + # Test that an invalid entity ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: "sensor.fake_entity", + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + # Test that an invalid device ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: "fake_device_id", + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + # Test that we can't include a bitmask value if parameter is a string + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: entity_entry.device_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + non_zwave_js_config_entry = MockConfigEntry(entry_id="fake_entry_id") + non_zwave_js_config_entry.add_to_hass(hass) + non_zwave_js_device = dev_reg.async_get_or_create( + config_entry_id=non_zwave_js_config_entry.entry_id, + identifiers={("test", "test")}, + ) + + # Test that a non Z-Wave JS device raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: non_zwave_js_device.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + zwave_js_device_with_invalid_node_id = dev_reg.async_get_or_create( + config_entry_id=integration.entry_id, identifiers={(DOMAIN, "500-500")} + ) + + # Test that a Z-Wave JS device with an invalid node ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: zwave_js_device_with_invalid_node_id.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + non_zwave_js_entity = ent_reg.async_get_or_create( + "test", + "sensor", + "test_sensor", + suggested_object_id="test_sensor", + config_entry=non_zwave_js_config_entry, + ) + + # Test that a non Z-Wave JS entity raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: non_zwave_js_entity.entity_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + +async def test_poll_value( + hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration +): + """Test the poll_value service.""" + # Test polling the primary value + client.async_send_command_no_wait.return_value = {"result": 2} + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.poll_value" + assert args["nodeId"] == 26 + assert args["valueId"] == { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 0, + "property": "mode", + "propertyName": "mode", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": { + "0": "Off", + "1": "Heat", + "2": "Cool", + "3": "Auto", + "11": "Energy heat", + "12": "Energy cool", + }, + }, + "value": 1, + "ccVersion": 2, + } + + client.async_send_command_no_wait.reset_mock() + + # Test polling all watched values + client.async_send_command_no_wait.return_value = {"result": 2} + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_REFRESH_ALL_VALUES: True, + }, + blocking=True, + ) + assert len(client.async_send_command_no_wait.call_args_list) == 8 + + # Test polling against an invalid entity raises ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + {ATTR_ENTITY_ID: "sensor.fake_entity_id"}, + blocking=True, + ) diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index a1d177cc5d8..dceaa17a816 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -2,6 +2,9 @@ from zwave_js_server.event import Event +from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import STATE_OFF, STATE_ON + from .common import SWITCH_ENTITY @@ -18,7 +21,7 @@ async def test_switch(hass, hank_binary_switch, integration, client): "switch", "turn_on", {"entity_id": SWITCH_ENTITY}, blocking=True ) - args = client.async_send_command.call_args[0][0] + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 32 assert args["valueId"] == { @@ -65,7 +68,7 @@ async def test_switch(hass, hank_binary_switch, integration, client): "switch", "turn_off", {"entity_id": SWITCH_ENTITY}, blocking=True ) - args = client.async_send_command.call_args[0][0] + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 32 assert args["valueId"] == { @@ -83,3 +86,141 @@ async def test_switch(hass, hank_binary_switch, integration, client): "value": False, } assert args["value"] is False + + +async def test_barrier_signaling_switch(hass, gdc_zw062, integration, client): + """Test barrier signaling state switch.""" + node = gdc_zw062 + entity = "switch.aeon_labs_garage_door_controller_gen5_signaling_state_visual" + + state = hass.states.get(entity) + assert state + assert state.state == "on" + + # Test turning off + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {"entity_id": entity}, blocking=True + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["value"] == 0 + assert args["valueId"] == { + "ccVersion": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "endpoint": 0, + "metadata": { + "label": "Signaling State (Visual)", + "max": 255, + "min": 0, + "readable": True, + "states": {"0": "Off", "255": "On"}, + "type": "number", + "writeable": True, + }, + "property": "signalingState", + "propertyKey": 2, + "propertyKeyName": "2", + "propertyName": "signalingState", + "value": 255, + } + + # state change is optimistic and writes state + await hass.async_block_till_done() + + state = hass.states.get(entity) + assert state.state == STATE_OFF + + client.async_send_command_no_wait.reset_mock() + + # Test turning on + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {"entity_id": entity}, blocking=True + ) + + # Note: the valueId's value is still 255 because we never + # received an updated value + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["value"] == 255 + assert args["valueId"] == { + "ccVersion": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "endpoint": 0, + "metadata": { + "label": "Signaling State (Visual)", + "max": 255, + "min": 0, + "readable": True, + "states": {"0": "Off", "255": "On"}, + "type": "number", + "writeable": True, + }, + "property": "signalingState", + "propertyKey": 2, + "propertyKeyName": "2", + "propertyName": "signalingState", + "value": 255, + } + + # state change is optimistic and writes state + await hass.async_block_till_done() + + state = hass.states.get(entity) + assert state.state == STATE_ON + + # Received a refresh off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "signalingState", + "propertyKey": 2, + "newValue": 0, + "prevValue": 0, + "propertyName": "signalingState", + "propertyKeyName": "2", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity) + assert state.state == STATE_OFF + + # Received a refresh off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 12, + "args": { + "commandClassName": "Barrier Operator", + "commandClass": 102, + "endpoint": 0, + "property": "signalingState", + "propertyKey": 2, + "newValue": 255, + "prevValue": 255, + "propertyName": "signalingState", + "propertyKeyName": "2", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity) + assert state.state == STATE_ON diff --git a/tests/conftest.py b/tests/conftest.py index 55249a58fc9..3fc2dc748cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ import requests_mock as _requests_mock from homeassistant import core as ha, loader, runner, util from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components import mqtt from homeassistant.components.websocket_api.auth import ( @@ -97,6 +98,21 @@ def verify_cleanup(): assert not threads +@pytest.fixture(autouse=True) +def bcrypt_cost(): + """Run with reduced rounds during tests, to speed up uses.""" + import bcrypt + + gensalt_orig = bcrypt.gensalt + + def gensalt_mock(rounds=12, prefix=b"2b"): + return gensalt_orig(4, prefix) + + bcrypt.gensalt = gensalt_mock + yield + bcrypt.gensalt = gensalt_orig + + @pytest.fixture def hass_storage(): """Fixture to mock storage.""" @@ -105,7 +121,17 @@ def hass_storage(): @pytest.fixture -def hass(loop, hass_storage, request): +def load_registries(): + """Fixture to control the loading of registries when setting up the hass fixture. + + To avoid loading the registries, tests can be marked with: + @pytest.mark.parametrize("load_registries", [False]) + """ + return True + + +@pytest.fixture +def hass(loop, load_registries, hass_storage, request): """Fixture to provide a test instance of Home Assistant.""" def exc_handle(loop, context): @@ -125,7 +151,7 @@ def hass(loop, hass_storage, request): orig_exception_handler(loop, context) exceptions = [] - hass = loop.run_until_complete(async_test_home_assistant(loop)) + hass = loop.run_until_complete(async_test_home_assistant(loop, load_registries)) orig_exception_handler = loop.get_exception_handler() loop.set_exception_handler(exc_handle) @@ -201,10 +227,24 @@ def mock_device_tracker_conf(): @pytest.fixture -def hass_access_token(hass, hass_admin_user): +async def hass_admin_credential(hass, local_auth): + """Provide credentials for admin user.""" + return Credentials( + id="mock-credential-id", + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": "admin"}, + is_new=False, + ) + + +@pytest.fixture +async def hass_access_token(hass, hass_admin_user, hass_admin_credential): """Return an access token to access Home Assistant.""" - refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID) + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential ) return hass.auth.async_create_access_token(refresh_token) @@ -234,10 +274,21 @@ def hass_read_only_user(hass, local_auth): @pytest.fixture -def hass_read_only_access_token(hass, hass_read_only_user): +def hass_read_only_access_token(hass, hass_read_only_user, local_auth): """Return a Home Assistant read only user.""" + credential = Credentials( + id="mock-readonly-credential-id", + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": "readonly"}, + is_new=False, + ) + hass_read_only_user.credentials.append(credential) + refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(hass_read_only_user, CLIENT_ID) + hass.auth.async_create_refresh_token( + hass_read_only_user, CLIENT_ID, credential=credential + ) ) return hass.auth.async_create_access_token(refresh_token) @@ -260,6 +311,7 @@ def local_auth(hass): prv = homeassistant.HassAuthProvider( hass, hass.auth._store, {"type": "homeassistant"} ) + hass.loop.run_until_complete(prv.async_initialize()) hass.auth._providers[(prv.type, prv.id)] = prv return prv diff --git a/tests/fixtures/aemet/station-3195-data.json b/tests/fixtures/aemet/station-3195-data.json new file mode 100644 index 00000000000..1784a5fb3a4 --- /dev/null +++ b/tests/fixtures/aemet/station-3195-data.json @@ -0,0 +1,369 @@ +[ { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T14:00:00", + "prec" : 1.2, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 929.9, + "hr" : 97.0, + "pres_nmar" : 1009.9, + "tamin" : -0.1, + "ta" : 0.1, + "tamax" : 0.2, + "tpr" : -0.3, + "rviento" : 132.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T15:00:00", + "prec" : 1.5, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 929.0, + "hr" : 98.0, + "pres_nmar" : 1008.9, + "tamin" : 0.1, + "ta" : 0.2, + "tamax" : 0.3, + "tpr" : 0.0, + "rviento" : 154.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T16:00:00", + "prec" : 0.7, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.8, + "hr" : 98.0, + "pres_nmar" : 1008.6, + "tamin" : 0.2, + "ta" : 0.3, + "tamax" : 0.3, + "tpr" : 0.0, + "rviento" : 177.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T17:00:00", + "prec" : 1.7, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.6, + "hr" : 99.0, + "pres_nmar" : 1008.5, + "tamin" : 0.1, + "ta" : 0.1, + "tamax" : 0.3, + "tpr" : 0.0, + "rviento" : 174.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T18:00:00", + "prec" : 1.9, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.2, + "hr" : 99.0, + "pres_nmar" : 1008.1, + "tamin" : -0.1, + "ta" : -0.1, + "tamax" : 0.1, + "tpr" : -0.3, + "rviento" : 163.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T19:00:00", + "prec" : 3.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.4, + "hr" : 99.0, + "pres_nmar" : 1008.4, + "tamin" : -0.3, + "ta" : -0.3, + "tamax" : 0.0, + "tpr" : -0.5, + "rviento" : 79.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T20:00:00", + "prec" : 3.5, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.4, + "hr" : 99.0, + "pres_nmar" : 1008.5, + "tamin" : -0.6, + "ta" : -0.6, + "tamax" : -0.3, + "tpr" : -0.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T21:00:00", + "prec" : 2.6, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 928.1, + "hr" : 99.0, + "pres_nmar" : 1008.2, + "tamin" : -0.7, + "ta" : -0.7, + "tamax" : -0.5, + "tpr" : -0.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T22:00:00", + "prec" : 3.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 927.6, + "hr" : 99.0, + "pres_nmar" : 1007.7, + "tamin" : -0.8, + "ta" : -0.8, + "tamax" : -0.7, + "tpr" : -1.0, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T23:00:00", + "prec" : 2.9, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 926.9, + "hr" : 99.0, + "pres_nmar" : 1007.0, + "tamin" : -0.9, + "ta" : -0.9, + "tamax" : -0.7, + "tpr" : -1.0, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T00:00:00", + "prec" : 1.4, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 926.5, + "hr" : 99.0, + "pres_nmar" : 1006.6, + "tamin" : -1.0, + "ta" : -1.0, + "tamax" : -0.8, + "tpr" : -1.2, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T01:00:00", + "prec" : 2.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.9, + "hr" : 99.0, + "pres_nmar" : 1006.0, + "tamin" : -1.3, + "ta" : -1.3, + "tamax" : -1.0, + "tpr" : -1.4, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T02:00:00", + "prec" : 1.5, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.7, + "hr" : 99.0, + "pres_nmar" : 1005.8, + "tamin" : -1.5, + "ta" : -1.4, + "tamax" : -1.3, + "tpr" : -1.4, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T03:00:00", + "prec" : 1.2, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.6, + "hr" : 99.0, + "pres_nmar" : 1005.7, + "tamin" : -1.5, + "ta" : -1.4, + "tamax" : -1.4, + "tpr" : -1.4, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T04:00:00", + "prec" : 1.1, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.9, + "hr" : 99.0, + "pres_nmar" : 1005.0, + "tamin" : -1.5, + "ta" : -1.5, + "tamax" : -1.4, + "tpr" : -1.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T05:00:00", + "prec" : 0.7, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.6, + "hr" : 99.0, + "pres_nmar" : 1004.7, + "tamin" : -1.5, + "ta" : -1.5, + "tamax" : -1.4, + "tpr" : -1.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T06:00:00", + "prec" : 0.2, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.4, + "hr" : 99.0, + "pres_nmar" : 1004.5, + "tamin" : -1.6, + "ta" : -1.6, + "tamax" : -1.5, + "tpr" : -1.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T07:00:00", + "prec" : 0.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.4, + "hr" : 99.0, + "pres_nmar" : 1004.5, + "tamin" : -1.6, + "ta" : -1.6, + "tamax" : -1.6, + "tpr" : -1.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T08:00:00", + "prec" : 0.1, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.8, + "hr" : 99.0, + "pres_nmar" : 1004.9, + "tamin" : -1.6, + "ta" : -1.6, + "tamax" : -1.5, + "tpr" : -1.7, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T09:00:00", + "prec" : 0.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.0, + "hr" : 99.0, + "pres_nmar" : 1005.0, + "tamin" : -1.6, + "ta" : -1.3, + "tamax" : -1.3, + "tpr" : -1.4, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T10:00:00", + "prec" : 0.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.3, + "hr" : 99.0, + "pres_nmar" : 1005.3, + "tamin" : -1.3, + "ta" : -1.2, + "tamax" : -1.1, + "tpr" : -1.4, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T11:00:00", + "prec" : 4.4, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 925.4, + "hr" : 99.0, + "pres_nmar" : 1005.4, + "tamin" : -1.2, + "ta" : -1.0, + "tamax" : -1.0, + "tpr" : -1.2, + "rviento" : 0.0 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-09T12:00:00", + "prec" : 7.0, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 924.6, + "hr" : 99.0, + "pres_nmar" : 1004.4, + "tamin" : -1.0, + "ta" : -0.7, + "tamax" : -0.6, + "tpr" : -0.7, + "rviento" : 0.0 +} ] diff --git a/tests/fixtures/aemet/station-3195.json b/tests/fixtures/aemet/station-3195.json new file mode 100644 index 00000000000..f97df3bea63 --- /dev/null +++ b/tests/fixtures/aemet/station-3195.json @@ -0,0 +1,6 @@ +{ + "descripcion" : "exito", + "estado" : 200, + "datos" : "https://opendata.aemet.es/opendata/sh/208c3ca3", + "metadatos" : "https://opendata.aemet.es/opendata/sh/55c2971b" +} diff --git a/tests/fixtures/aemet/station-list-data.json b/tests/fixtures/aemet/station-list-data.json new file mode 100644 index 00000000000..8b35bff6e4a --- /dev/null +++ b/tests/fixtures/aemet/station-list-data.json @@ -0,0 +1,42 @@ +[ { + "idema" : "3194U", + "lon" : -3.724167, + "fint" : "2021-01-08T14:00:00", + "prec" : 1.3, + "alt" : 664.0, + "lat" : 40.45167, + "ubi" : "MADRID C. UNIVERSITARIA", + "hr" : 98.0, + "tamin" : 0.6, + "ta" : 0.9, + "tamax" : 1.0, + "tpr" : 0.6 +}, { + "idema" : "3194Y", + "lon" : -3.813369, + "fint" : "2021-01-08T14:00:00", + "prec" : 0.2, + "alt" : 665.0, + "lat" : 40.448437, + "ubi" : "POZUELO DE ALARCON (AUTOM�TICA)", + "hr" : 93.0, + "tamin" : 0.5, + "ta" : 0.6, + "tamax" : 0.6 +}, { + "idema" : "3195", + "lon" : -3.678095, + "fint" : "2021-01-08T14:00:00", + "prec" : 1.2, + "alt" : 667.0, + "lat" : 40.411804, + "ubi" : "MADRID RETIRO", + "pres" : 929.9, + "hr" : 97.0, + "pres_nmar" : 1009.9, + "tamin" : -0.1, + "ta" : 0.1, + "tamax" : 0.2, + "tpr" : -0.3, + "rviento" : 132.0 +} ] diff --git a/tests/fixtures/aemet/station-list.json b/tests/fixtures/aemet/station-list.json new file mode 100644 index 00000000000..6e0dbc97d6d --- /dev/null +++ b/tests/fixtures/aemet/station-list.json @@ -0,0 +1,6 @@ +{ + "descripcion" : "exito", + "estado" : 200, + "datos" : "https://opendata.aemet.es/opendata/sh/2c55192f", + "metadatos" : "https://opendata.aemet.es/opendata/sh/55c2971b" +} diff --git a/tests/fixtures/aemet/town-28065-forecast-daily-data.json b/tests/fixtures/aemet/town-28065-forecast-daily-data.json new file mode 100644 index 00000000000..77877c72f3a --- /dev/null +++ b/tests/fixtures/aemet/town-28065-forecast-daily-data.json @@ -0,0 +1,625 @@ +[ { + "origen" : { + "productor" : "Agencia Estatal de Meteorolog�a - AEMET. Gobierno de Espa�a", + "web" : "http://www.aemet.es", + "enlace" : "http://www.aemet.es/es/eltiempo/prediccion/municipios/getafe-id28065", + "language" : "es", + "copyright" : "� AEMET. Autorizado el uso de la informaci�n y su reproducci�n citando a AEMET como autora de la misma.", + "notaLegal" : "http://www.aemet.es/es/nota_legal" + }, + "elaborado" : "2021-01-09T11:54:00", + "nombre" : "Getafe", + "provincia" : "Madrid", + "prediccion" : { + "dia" : [ { + "probPrecipitacion" : [ { + "value" : 0, + "periodo" : "00-24" + }, { + "value" : 0, + "periodo" : "00-12" + }, { + "value" : 100, + "periodo" : "12-24" + }, { + "value" : 0, + "periodo" : "00-06" + }, { + "value" : 100, + "periodo" : "06-12" + }, { + "value" : 100, + "periodo" : "12-18" + }, { + "value" : 100, + "periodo" : "18-24" + } ], + "cotaNieveProv" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "500", + "periodo" : "12-24" + }, { + "value" : "", + "periodo" : "00-06" + }, { + "value" : "400", + "periodo" : "06-12" + }, { + "value" : "500", + "periodo" : "12-18" + }, { + "value" : "600", + "periodo" : "18-24" + } ], + "estadoCielo" : [ { + "value" : "", + "periodo" : "00-24", + "descripcion" : "" + }, { + "value" : "", + "periodo" : "00-12", + "descripcion" : "" + }, { + "value" : "36", + "periodo" : "12-24", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "", + "periodo" : "00-06", + "descripcion" : "" + }, { + "value" : "36", + "periodo" : "06-12", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "12-18", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "34n", + "periodo" : "18-24", + "descripcion" : "Nuboso con nieve" + } ], + "viento" : [ { + "direccion" : "", + "velocidad" : 0, + "periodo" : "00-24" + }, { + "direccion" : "", + "velocidad" : 0, + "periodo" : "00-12" + }, { + "direccion" : "E", + "velocidad" : 15, + "periodo" : "12-24" + }, { + "direccion" : "NE", + "velocidad" : 30, + "periodo" : "00-06" + }, { + "direccion" : "E", + "velocidad" : 15, + "periodo" : "06-12" + }, { + "direccion" : "E", + "velocidad" : 5, + "periodo" : "12-18" + }, { + "direccion" : "NE", + "velocidad" : 5, + "periodo" : "18-24" + } ], + "rachaMax" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + }, { + "value" : "40", + "periodo" : "00-06" + }, { + "value" : "", + "periodo" : "06-12" + }, { + "value" : "", + "periodo" : "12-18" + }, { + "value" : "", + "periodo" : "18-24" + } ], + "temperatura" : { + "maxima" : 2, + "minima" : -1, + "dato" : [ { + "value" : -1, + "hora" : 6 + }, { + "value" : 0, + "hora" : 12 + }, { + "value" : 1, + "hora" : 18 + }, { + "value" : 1, + "hora" : 24 + } ] + }, + "sensTermica" : { + "maxima" : 1, + "minima" : -9, + "dato" : [ { + "value" : -1, + "hora" : 6 + }, { + "value" : -4, + "hora" : 12 + }, { + "value" : 1, + "hora" : 18 + }, { + "value" : 1, + "hora" : 24 + } ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 75, + "dato" : [ { + "value" : 100, + "hora" : 6 + }, { + "value" : 100, + "hora" : 12 + }, { + "value" : 95, + "hora" : 18 + }, { + "value" : 75, + "hora" : 24 + } ] + }, + "uvMax" : 1, + "fecha" : "2021-01-09T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 30, + "periodo" : "00-24" + }, { + "value" : 25, + "periodo" : "00-12" + }, { + "value" : 5, + "periodo" : "12-24" + }, { + "value" : 5, + "periodo" : "00-06" + }, { + "value" : 15, + "periodo" : "06-12" + }, { + "value" : 5, + "periodo" : "12-18" + }, { + "value" : 0, + "periodo" : "18-24" + } ], + "cotaNieveProv" : [ { + "value" : "600", + "periodo" : "00-24" + }, { + "value" : "600", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + }, { + "value" : "", + "periodo" : "00-06" + }, { + "value" : "600", + "periodo" : "06-12" + }, { + "value" : "", + "periodo" : "12-18" + }, { + "value" : "", + "periodo" : "18-24" + } ], + "estadoCielo" : [ { + "value" : "13", + "periodo" : "00-24", + "descripcion" : "Intervalos nubosos" + }, { + "value" : "15", + "periodo" : "00-12", + "descripcion" : "Muy nuboso" + }, { + "value" : "12", + "periodo" : "12-24", + "descripcion" : "Poco nuboso" + }, { + "value" : "14n", + "periodo" : "00-06", + "descripcion" : "Nuboso" + }, { + "value" : "15", + "periodo" : "06-12", + "descripcion" : "Muy nuboso" + }, { + "value" : "12", + "periodo" : "12-18", + "descripcion" : "Poco nuboso" + }, { + "value" : "12n", + "periodo" : "18-24", + "descripcion" : "Poco nuboso" + } ], + "viento" : [ { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "00-24" + }, { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "00-12" + }, { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "12-24" + }, { + "direccion" : "N", + "velocidad" : 10, + "periodo" : "00-06" + }, { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "06-12" + }, { + "direccion" : "NE", + "velocidad" : 15, + "periodo" : "12-18" + }, { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "18-24" + } ], + "rachaMax" : [ { + "value" : "30", + "periodo" : "00-24" + }, { + "value" : "30", + "periodo" : "00-12" + }, { + "value" : "30", + "periodo" : "12-24" + }, { + "value" : "", + "periodo" : "00-06" + }, { + "value" : "30", + "periodo" : "06-12" + }, { + "value" : "", + "periodo" : "12-18" + }, { + "value" : "", + "periodo" : "18-24" + } ], + "temperatura" : { + "maxima" : 4, + "minima" : -4, + "dato" : [ { + "value" : -1, + "hora" : 6 + }, { + "value" : 3, + "hora" : 12 + }, { + "value" : 1, + "hora" : 18 + }, { + "value" : -1, + "hora" : 24 + } ] + }, + "sensTermica" : { + "maxima" : 1, + "minima" : -7, + "dato" : [ { + "value" : -4, + "hora" : 6 + }, { + "value" : -2, + "hora" : 12 + }, { + "value" : -4, + "hora" : 18 + }, { + "value" : -6, + "hora" : 24 + } ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 70, + "dato" : [ { + "value" : 90, + "hora" : 6 + }, { + "value" : 75, + "hora" : 12 + }, { + "value" : 80, + "hora" : 18 + }, { + "value" : 80, + "hora" : 24 + } ] + }, + "uvMax" : 1, + "fecha" : "2021-01-10T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 0, + "periodo" : "00-24" + }, { + "value" : 0, + "periodo" : "00-12" + }, { + "value" : 0, + "periodo" : "12-24" + } ], + "cotaNieveProv" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + } ], + "estadoCielo" : [ { + "value" : "12", + "periodo" : "00-24", + "descripcion" : "Poco nuboso" + }, { + "value" : "12", + "periodo" : "00-12", + "descripcion" : "Poco nuboso" + }, { + "value" : "12", + "periodo" : "12-24", + "descripcion" : "Poco nuboso" + } ], + "viento" : [ { + "direccion" : "N", + "velocidad" : 5, + "periodo" : "00-24" + }, { + "direccion" : "NE", + "velocidad" : 20, + "periodo" : "00-12" + }, { + "direccion" : "NO", + "velocidad" : 10, + "periodo" : "12-24" + } ], + "rachaMax" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + } ], + "temperatura" : { + "maxima" : 3, + "minima" : -7, + "dato" : [ ] + }, + "sensTermica" : { + "maxima" : 3, + "minima" : -8, + "dato" : [ ] + }, + "humedadRelativa" : { + "maxima" : 85, + "minima" : 60, + "dato" : [ ] + }, + "uvMax" : 1, + "fecha" : "2021-01-11T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 0, + "periodo" : "00-24" + }, { + "value" : 0, + "periodo" : "00-12" + }, { + "value" : 0, + "periodo" : "12-24" + } ], + "cotaNieveProv" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + } ], + "estadoCielo" : [ { + "value" : "12", + "periodo" : "00-24", + "descripcion" : "Poco nuboso" + }, { + "value" : "12", + "periodo" : "00-12", + "descripcion" : "Poco nuboso" + }, { + "value" : "12", + "periodo" : "12-24", + "descripcion" : "Poco nuboso" + } ], + "viento" : [ { + "direccion" : "C", + "velocidad" : 0, + "periodo" : "00-24" + }, { + "direccion" : "E", + "velocidad" : 5, + "periodo" : "00-12" + }, { + "direccion" : "C", + "velocidad" : 0, + "periodo" : "12-24" + } ], + "rachaMax" : [ { + "value" : "", + "periodo" : "00-24" + }, { + "value" : "", + "periodo" : "00-12" + }, { + "value" : "", + "periodo" : "12-24" + } ], + "temperatura" : { + "maxima" : -1, + "minima" : -13, + "dato" : [ ] + }, + "sensTermica" : { + "maxima" : -1, + "minima" : -13, + "dato" : [ ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 65, + "dato" : [ ] + }, + "uvMax" : 2, + "fecha" : "2021-01-12T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 0 + } ], + "cotaNieveProv" : [ { + "value" : "" + } ], + "estadoCielo" : [ { + "value" : "11", + "descripcion" : "Despejado" + } ], + "viento" : [ { + "direccion" : "C", + "velocidad" : 0 + } ], + "rachaMax" : [ { + "value" : "" + } ], + "temperatura" : { + "maxima" : 6, + "minima" : -11, + "dato" : [ ] + }, + "sensTermica" : { + "maxima" : 6, + "minima" : -11, + "dato" : [ ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 65, + "dato" : [ ] + }, + "uvMax" : 2, + "fecha" : "2021-01-13T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 0 + } ], + "cotaNieveProv" : [ { + "value" : "" + } ], + "estadoCielo" : [ { + "value" : "12", + "descripcion" : "Poco nuboso" + } ], + "viento" : [ { + "direccion" : "C", + "velocidad" : 0 + } ], + "rachaMax" : [ { + "value" : "" + } ], + "temperatura" : { + "maxima" : 6, + "minima" : -7, + "dato" : [ ] + }, + "sensTermica" : { + "maxima" : 6, + "minima" : -7, + "dato" : [ ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 80, + "dato" : [ ] + }, + "fecha" : "2021-01-14T00:00:00" + }, { + "probPrecipitacion" : [ { + "value" : 0 + } ], + "cotaNieveProv" : [ { + "value" : "" + } ], + "estadoCielo" : [ { + "value" : "14", + "descripcion" : "Nuboso" + } ], + "viento" : [ { + "direccion" : "C", + "velocidad" : 0 + } ], + "rachaMax" : [ { + "value" : "" + } ], + "temperatura" : { + "maxima" : 5, + "minima" : -4, + "dato" : [ ] + }, + "sensTermica" : { + "maxima" : 5, + "minima" : -4, + "dato" : [ ] + }, + "humedadRelativa" : { + "maxima" : 100, + "minima" : 55, + "dato" : [ ] + }, + "fecha" : "2021-01-15T00:00:00" + } ] + }, + "id" : 28065, + "version" : 1.0 +} ] diff --git a/tests/fixtures/aemet/town-28065-forecast-daily.json b/tests/fixtures/aemet/town-28065-forecast-daily.json new file mode 100644 index 00000000000..35935658c50 --- /dev/null +++ b/tests/fixtures/aemet/town-28065-forecast-daily.json @@ -0,0 +1,6 @@ +{ + "descripcion" : "exito", + "estado" : 200, + "datos" : "https://opendata.aemet.es/opendata/sh/64e29abb", + "metadatos" : "https://opendata.aemet.es/opendata/sh/dfd88b22" +} diff --git a/tests/fixtures/aemet/town-28065-forecast-hourly-data.json b/tests/fixtures/aemet/town-28065-forecast-hourly-data.json new file mode 100644 index 00000000000..2bd3a22235a --- /dev/null +++ b/tests/fixtures/aemet/town-28065-forecast-hourly-data.json @@ -0,0 +1,1416 @@ +[ { + "origen" : { + "productor" : "Agencia Estatal de Meteorolog�a - AEMET. Gobierno de Espa�a", + "web" : "http://www.aemet.es", + "enlace" : "http://www.aemet.es/es/eltiempo/prediccion/municipios/horas/getafe-id28065", + "language" : "es", + "copyright" : "� AEMET. Autorizado el uso de la informaci�n y su reproducci�n citando a AEMET como autora de la misma.", + "notaLegal" : "http://www.aemet.es/es/nota_legal" + }, + "elaborado" : "2021-01-09T11:47:45", + "nombre" : "Getafe", + "provincia" : "Madrid", + "prediccion" : { + "dia" : [ { + "estadoCielo" : [ { + "value" : "36n", + "periodo" : "07", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36n", + "periodo" : "08", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "09", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "10", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "11", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "12", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "13", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "46", + "periodo" : "14", + "descripcion" : "Cubierto con lluvia escasa" + }, { + "value" : "46", + "periodo" : "15", + "descripcion" : "Cubierto con lluvia escasa" + }, { + "value" : "36", + "periodo" : "16", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "36", + "periodo" : "17", + "descripcion" : "Cubierto con nieve" + }, { + "value" : "74n", + "periodo" : "18", + "descripcion" : "Cubierto con nieve escasa" + }, { + "value" : "46n", + "periodo" : "19", + "descripcion" : "Cubierto con lluvia escasa" + }, { + "value" : "46n", + "periodo" : "20", + "descripcion" : "Cubierto con lluvia escasa" + }, { + "value" : "16n", + "periodo" : "21", + "descripcion" : "Cubierto" + }, { + "value" : "16n", + "periodo" : "22", + "descripcion" : "Cubierto" + }, { + "value" : "12n", + "periodo" : "23", + "descripcion" : "Poco nuboso" + } ], + "precipitacion" : [ { + "value" : "1.4", + "periodo" : "07" + }, { + "value" : "2.1", + "periodo" : "08" + }, { + "value" : "1.9", + "periodo" : "09" + }, { + "value" : "2", + "periodo" : "10" + }, { + "value" : "1.9", + "periodo" : "11" + }, { + "value" : "1.8", + "periodo" : "12" + }, { + "value" : "1.5", + "periodo" : "13" + }, { + "value" : "0.5", + "periodo" : "14" + }, { + "value" : "0.6", + "periodo" : "15" + }, { + "value" : "0.8", + "periodo" : "16" + }, { + "value" : "0.6", + "periodo" : "17" + }, { + "value" : "0.2", + "periodo" : "18" + }, { + "value" : "0.2", + "periodo" : "19" + }, { + "value" : "0.1", + "periodo" : "20" + }, { + "value" : "0", + "periodo" : "21" + }, { + "value" : "0", + "periodo" : "22" + }, { + "value" : "0", + "periodo" : "23" + } ], + "probPrecipitacion" : [ { + "value" : "", + "periodo" : "0107" + }, { + "value" : "100", + "periodo" : "0713" + }, { + "value" : "100", + "periodo" : "1319" + }, { + "value" : "100", + "periodo" : "1901" + } ], + "probTormenta" : [ { + "value" : "", + "periodo" : "0107" + }, { + "value" : "0", + "periodo" : "0713" + }, { + "value" : "0", + "periodo" : "1319" + }, { + "value" : "0", + "periodo" : "1901" + } ], + "nieve" : [ { + "value" : "1.4", + "periodo" : "07" + }, { + "value" : "2.1", + "periodo" : "08" + }, { + "value" : "1.9", + "periodo" : "09" + }, { + "value" : "2", + "periodo" : "10" + }, { + "value" : "1.9", + "periodo" : "11" + }, { + "value" : "1.8", + "periodo" : "12" + }, { + "value" : "1.2", + "periodo" : "13" + }, { + "value" : "0.1", + "periodo" : "14" + }, { + "value" : "0.2", + "periodo" : "15" + }, { + "value" : "0.6", + "periodo" : "16" + }, { + "value" : "0.6", + "periodo" : "17" + }, { + "value" : "0.2", + "periodo" : "18" + }, { + "value" : "0.1", + "periodo" : "19" + }, { + "value" : "0", + "periodo" : "20" + }, { + "value" : "0", + "periodo" : "21" + }, { + "value" : "0", + "periodo" : "22" + }, { + "value" : "0", + "periodo" : "23" + } ], + "probNieve" : [ { + "value" : "", + "periodo" : "0107" + }, { + "value" : "100", + "periodo" : "0713" + }, { + "value" : "100", + "periodo" : "1319" + }, { + "value" : "80", + "periodo" : "1901" + } ], + "temperatura" : [ { + "value" : "-1", + "periodo" : "07" + }, { + "value" : "-1", + "periodo" : "08" + }, { + "value" : "-1", + "periodo" : "09" + }, { + "value" : "-1", + "periodo" : "10" + }, { + "value" : "-1", + "periodo" : "11" + }, { + "value" : "-0", + "periodo" : "12" + }, { + "value" : "-0", + "periodo" : "13" + }, { + "value" : "0", + "periodo" : "14" + }, { + "value" : "1", + "periodo" : "15" + }, { + "value" : "1", + "periodo" : "16" + }, { + "value" : "1", + "periodo" : "17" + }, { + "value" : "1", + "periodo" : "18" + }, { + "value" : "1", + "periodo" : "19" + }, { + "value" : "1", + "periodo" : "20" + }, { + "value" : "1", + "periodo" : "21" + }, { + "value" : "1", + "periodo" : "22" + }, { + "value" : "1", + "periodo" : "23" + } ], + "sensTermica" : [ { + "value" : "-8", + "periodo" : "07" + }, { + "value" : "-7", + "periodo" : "08" + }, { + "value" : "-7", + "periodo" : "09" + }, { + "value" : "-6", + "periodo" : "10" + }, { + "value" : "-6", + "periodo" : "11" + }, { + "value" : "-4", + "periodo" : "12" + }, { + "value" : "-4", + "periodo" : "13" + }, { + "value" : "-4", + "periodo" : "14" + }, { + "value" : "-2", + "periodo" : "15" + }, { + "value" : "-2", + "periodo" : "16" + }, { + "value" : "-2", + "periodo" : "17" + }, { + "value" : "1", + "periodo" : "18" + }, { + "value" : "-2", + "periodo" : "19" + }, { + "value" : "1", + "periodo" : "20" + }, { + "value" : "1", + "periodo" : "21" + }, { + "value" : "1", + "periodo" : "22" + }, { + "value" : "-2", + "periodo" : "23" + } ], + "humedadRelativa" : [ { + "value" : "96", + "periodo" : "07" + }, { + "value" : "96", + "periodo" : "08" + }, { + "value" : "99", + "periodo" : "09" + }, { + "value" : "100", + "periodo" : "10" + }, { + "value" : "100", + "periodo" : "11" + }, { + "value" : "100", + "periodo" : "12" + }, { + "value" : "100", + "periodo" : "13" + }, { + "value" : "100", + "periodo" : "14" + }, { + "value" : "100", + "periodo" : "15" + }, { + "value" : "97", + "periodo" : "16" + }, { + "value" : "94", + "periodo" : "17" + }, { + "value" : "93", + "periodo" : "18" + }, { + "value" : "93", + "periodo" : "19" + }, { + "value" : "92", + "periodo" : "20" + }, { + "value" : "89", + "periodo" : "21" + }, { + "value" : "89", + "periodo" : "22" + }, { + "value" : "85", + "periodo" : "23" + } ], + "vientoAndRachaMax" : [ { + "direccion" : [ "NE" ], + "velocidad" : [ "28" ], + "periodo" : "07" + }, { + "value" : "41", + "periodo" : "07" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "27" ], + "periodo" : "08" + }, { + "value" : "41", + "periodo" : "08" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "25" ], + "periodo" : "09" + }, { + "value" : "39", + "periodo" : "09" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "20" ], + "periodo" : "10" + }, { + "value" : "36", + "periodo" : "10" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "11" + }, { + "value" : "29", + "periodo" : "11" + }, { + "direccion" : [ "E" ], + "velocidad" : [ "15" ], + "periodo" : "12" + }, { + "value" : "24", + "periodo" : "12" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "15" ], + "periodo" : "13" + }, { + "value" : "22", + "periodo" : "13" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "14" ], + "periodo" : "14" + }, { + "value" : "24", + "periodo" : "14" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "10" ], + "periodo" : "15" + }, { + "value" : "20", + "periodo" : "15" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "8" ], + "periodo" : "16" + }, { + "value" : "14", + "periodo" : "16" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "9" ], + "periodo" : "17" + }, { + "value" : "13", + "periodo" : "17" + }, { + "direccion" : [ "E" ], + "velocidad" : [ "7" ], + "periodo" : "18" + }, { + "value" : "13", + "periodo" : "18" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "8" ], + "periodo" : "19" + }, { + "value" : "12", + "periodo" : "19" + }, { + "direccion" : [ "SE" ], + "velocidad" : [ "6" ], + "periodo" : "20" + }, { + "value" : "12", + "periodo" : "20" + }, { + "direccion" : [ "E" ], + "velocidad" : [ "6" ], + "periodo" : "21" + }, { + "value" : "8", + "periodo" : "21" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "6" ], + "periodo" : "22" + }, { + "value" : "9", + "periodo" : "22" + }, { + "direccion" : [ "E" ], + "velocidad" : [ "8" ], + "periodo" : "23" + }, { + "value" : "11", + "periodo" : "23" + } ], + "fecha" : "2021-01-09T00:00:00", + "orto" : "08:37", + "ocaso" : "18:07" + }, { + "estadoCielo" : [ { + "value" : "12n", + "periodo" : "00", + "descripcion" : "Poco nuboso" + }, { + "value" : "81n", + "periodo" : "01", + "descripcion" : "Niebla" + }, { + "value" : "81n", + "periodo" : "02", + "descripcion" : "Niebla" + }, { + "value" : "81n", + "periodo" : "03", + "descripcion" : "Niebla" + }, { + "value" : "17n", + "periodo" : "04", + "descripcion" : "Nubes altas" + }, { + "value" : "16n", + "periodo" : "05", + "descripcion" : "Cubierto" + }, { + "value" : "16n", + "periodo" : "06", + "descripcion" : "Cubierto" + }, { + "value" : "16n", + "periodo" : "07", + "descripcion" : "Cubierto" + }, { + "value" : "16n", + "periodo" : "08", + "descripcion" : "Cubierto" + }, { + "value" : "14", + "periodo" : "09", + "descripcion" : "Nuboso" + }, { + "value" : "12", + "periodo" : "10", + "descripcion" : "Poco nuboso" + }, { + "value" : "12", + "periodo" : "11", + "descripcion" : "Poco nuboso" + }, { + "value" : "17", + "periodo" : "12", + "descripcion" : "Nubes altas" + }, { + "value" : "17", + "periodo" : "13", + "descripcion" : "Nubes altas" + }, { + "value" : "17", + "periodo" : "14", + "descripcion" : "Nubes altas" + }, { + "value" : "17", + "periodo" : "15", + "descripcion" : "Nubes altas" + }, { + "value" : "17", + "periodo" : "16", + "descripcion" : "Nubes altas" + }, { + "value" : "17", + "periodo" : "17", + "descripcion" : "Nubes altas" + }, { + "value" : "12n", + "periodo" : "18", + "descripcion" : "Poco nuboso" + }, { + "value" : "12n", + "periodo" : "19", + "descripcion" : "Poco nuboso" + }, { + "value" : "14n", + "periodo" : "20", + "descripcion" : "Nuboso" + }, { + "value" : "16n", + "periodo" : "21", + "descripcion" : "Cubierto" + }, { + "value" : "16n", + "periodo" : "22", + "descripcion" : "Cubierto" + }, { + "value" : "15n", + "periodo" : "23", + "descripcion" : "Muy nuboso" + } ], + "precipitacion" : [ { + "value" : "0", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "0", + "periodo" : "02" + }, { + "value" : "0", + "periodo" : "03" + }, { + "value" : "0", + "periodo" : "04" + }, { + "value" : "0", + "periodo" : "05" + }, { + "value" : "0", + "periodo" : "06" + }, { + "value" : "0", + "periodo" : "07" + }, { + "value" : "0", + "periodo" : "08" + }, { + "value" : "Ip", + "periodo" : "09" + }, { + "value" : "0", + "periodo" : "10" + }, { + "value" : "0", + "periodo" : "11" + }, { + "value" : "0", + "periodo" : "12" + }, { + "value" : "0", + "periodo" : "13" + }, { + "value" : "0", + "periodo" : "14" + }, { + "value" : "0", + "periodo" : "15" + }, { + "value" : "0", + "periodo" : "16" + }, { + "value" : "0", + "periodo" : "17" + }, { + "value" : "0", + "periodo" : "18" + }, { + "value" : "0", + "periodo" : "19" + }, { + "value" : "0", + "periodo" : "20" + }, { + "value" : "0", + "periodo" : "21" + }, { + "value" : "0", + "periodo" : "22" + }, { + "value" : "0", + "periodo" : "23" + } ], + "probPrecipitacion" : [ { + "value" : "10", + "periodo" : "0107" + }, { + "value" : "15", + "periodo" : "0713" + }, { + "value" : "5", + "periodo" : "1319" + }, { + "value" : "0", + "periodo" : "1901" + } ], + "probTormenta" : [ { + "value" : "0", + "periodo" : "0107" + }, { + "value" : "0", + "periodo" : "0713" + }, { + "value" : "0", + "periodo" : "1319" + }, { + "value" : "0", + "periodo" : "1901" + } ], + "nieve" : [ { + "value" : "0", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "0", + "periodo" : "02" + }, { + "value" : "0", + "periodo" : "03" + }, { + "value" : "0", + "periodo" : "04" + }, { + "value" : "0", + "periodo" : "05" + }, { + "value" : "0", + "periodo" : "06" + }, { + "value" : "0", + "periodo" : "07" + }, { + "value" : "0", + "periodo" : "08" + }, { + "value" : "Ip", + "periodo" : "09" + }, { + "value" : "0", + "periodo" : "10" + }, { + "value" : "0", + "periodo" : "11" + }, { + "value" : "0", + "periodo" : "12" + }, { + "value" : "0", + "periodo" : "13" + }, { + "value" : "0", + "periodo" : "14" + }, { + "value" : "0", + "periodo" : "15" + }, { + "value" : "0", + "periodo" : "16" + }, { + "value" : "0", + "periodo" : "17" + }, { + "value" : "0", + "periodo" : "18" + }, { + "value" : "0", + "periodo" : "19" + }, { + "value" : "0", + "periodo" : "20" + }, { + "value" : "0", + "periodo" : "21" + }, { + "value" : "0", + "periodo" : "22" + }, { + "value" : "0", + "periodo" : "23" + } ], + "probNieve" : [ { + "value" : "10", + "periodo" : "0107" + }, { + "value" : "10", + "periodo" : "0713" + }, { + "value" : "0", + "periodo" : "1319" + }, { + "value" : "0", + "periodo" : "1901" + } ], + "temperatura" : [ { + "value" : "1", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "-0", + "periodo" : "02" + }, { + "value" : "-0", + "periodo" : "03" + }, { + "value" : "-1", + "periodo" : "04" + }, { + "value" : "-1", + "periodo" : "05" + }, { + "value" : "-1", + "periodo" : "06" + }, { + "value" : "-2", + "periodo" : "07" + }, { + "value" : "-1", + "periodo" : "08" + }, { + "value" : "-1", + "periodo" : "09" + }, { + "value" : "0", + "periodo" : "10" + }, { + "value" : "2", + "periodo" : "11" + }, { + "value" : "3", + "periodo" : "12" + }, { + "value" : "3", + "periodo" : "13" + }, { + "value" : "3", + "periodo" : "14" + }, { + "value" : "4", + "periodo" : "15" + }, { + "value" : "3", + "periodo" : "16" + }, { + "value" : "2", + "periodo" : "17" + }, { + "value" : "1", + "periodo" : "18" + }, { + "value" : "1", + "periodo" : "19" + }, { + "value" : "1", + "periodo" : "20" + }, { + "value" : "1", + "periodo" : "21" + }, { + "value" : "0", + "periodo" : "22" + }, { + "value" : "-0", + "periodo" : "23" + } ], + "sensTermica" : [ { + "value" : "1", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "-0", + "periodo" : "02" + }, { + "value" : "-0", + "periodo" : "03" + }, { + "value" : "-4", + "periodo" : "04" + }, { + "value" : "-1", + "periodo" : "05" + }, { + "value" : "-4", + "periodo" : "06" + }, { + "value" : "-6", + "periodo" : "07" + }, { + "value" : "-6", + "periodo" : "08" + }, { + "value" : "-7", + "periodo" : "09" + }, { + "value" : "-5", + "periodo" : "10" + }, { + "value" : "-3", + "periodo" : "11" + }, { + "value" : "-2", + "periodo" : "12" + }, { + "value" : "-1", + "periodo" : "13" + }, { + "value" : "-1", + "periodo" : "14" + }, { + "value" : "0", + "periodo" : "15" + }, { + "value" : "-1", + "periodo" : "16" + }, { + "value" : "-2", + "periodo" : "17" + }, { + "value" : "-4", + "periodo" : "18" + }, { + "value" : "-4", + "periodo" : "19" + }, { + "value" : "-3", + "periodo" : "20" + }, { + "value" : "-4", + "periodo" : "21" + }, { + "value" : "-5", + "periodo" : "22" + }, { + "value" : "-5", + "periodo" : "23" + } ], + "humedadRelativa" : [ { + "value" : "74", + "periodo" : "00" + }, { + "value" : "71", + "periodo" : "01" + }, { + "value" : "80", + "periodo" : "02" + }, { + "value" : "84", + "periodo" : "03" + }, { + "value" : "81", + "periodo" : "04" + }, { + "value" : "78", + "periodo" : "05" + }, { + "value" : "90", + "periodo" : "06" + }, { + "value" : "100", + "periodo" : "07" + }, { + "value" : "100", + "periodo" : "08" + }, { + "value" : "93", + "periodo" : "09" + }, { + "value" : "84", + "periodo" : "10" + }, { + "value" : "78", + "periodo" : "11" + }, { + "value" : "73", + "periodo" : "12" + }, { + "value" : "74", + "periodo" : "13" + }, { + "value" : "74", + "periodo" : "14" + }, { + "value" : "73", + "periodo" : "15" + }, { + "value" : "78", + "periodo" : "16" + }, { + "value" : "79", + "periodo" : "17" + }, { + "value" : "79", + "periodo" : "18" + }, { + "value" : "77", + "periodo" : "19" + }, { + "value" : "75", + "periodo" : "20" + }, { + "value" : "77", + "periodo" : "21" + }, { + "value" : "80", + "periodo" : "22" + }, { + "value" : "80", + "periodo" : "23" + } ], + "vientoAndRachaMax" : [ { + "direccion" : [ "NE" ], + "velocidad" : [ "6" ], + "periodo" : "00" + }, { + "value" : "12", + "periodo" : "00" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "5" ], + "periodo" : "01" + }, { + "value" : "10", + "periodo" : "01" + }, { + "direccion" : [ "N" ], + "velocidad" : [ "6" ], + "periodo" : "02" + }, { + "value" : "11", + "periodo" : "02" + }, { + "direccion" : [ "N" ], + "velocidad" : [ "6" ], + "periodo" : "03" + }, { + "value" : "9", + "periodo" : "03" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "8" ], + "periodo" : "04" + }, { + "value" : "12", + "periodo" : "04" + }, { + "direccion" : [ "N" ], + "velocidad" : [ "5" ], + "periodo" : "05" + }, { + "value" : "11", + "periodo" : "05" + }, { + "direccion" : [ "N" ], + "velocidad" : [ "9" ], + "periodo" : "06" + }, { + "value" : "13", + "periodo" : "06" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "13" ], + "periodo" : "07" + }, { + "value" : "18", + "periodo" : "07" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "08" + }, { + "value" : "25", + "periodo" : "08" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "21" ], + "periodo" : "09" + }, { + "value" : "31", + "periodo" : "09" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "21" ], + "periodo" : "10" + }, { + "value" : "32", + "periodo" : "10" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "21" ], + "periodo" : "11" + }, { + "value" : "30", + "periodo" : "11" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "22" ], + "periodo" : "12" + }, { + "value" : "32", + "periodo" : "12" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "20" ], + "periodo" : "13" + }, { + "value" : "32", + "periodo" : "13" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "19" ], + "periodo" : "14" + }, { + "value" : "30", + "periodo" : "14" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "15" + }, { + "value" : "28", + "periodo" : "15" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "16" ], + "periodo" : "16" + }, { + "value" : "25", + "periodo" : "16" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "16" ], + "periodo" : "17" + }, { + "value" : "24", + "periodo" : "17" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "18" + }, { + "value" : "24", + "periodo" : "18" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "19" + }, { + "value" : "25", + "periodo" : "19" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "16" ], + "periodo" : "20" + }, { + "value" : "25", + "periodo" : "20" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "17" ], + "periodo" : "21" + }, { + "value" : "24", + "periodo" : "21" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "19" ], + "periodo" : "22" + }, { + "value" : "27", + "periodo" : "22" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "21" ], + "periodo" : "23" + }, { + "value" : "30", + "periodo" : "23" + } ], + "fecha" : "2021-01-10T00:00:00", + "orto" : "08:36", + "ocaso" : "18:08" + }, { + "estadoCielo" : [ { + "value" : "14n", + "periodo" : "00", + "descripcion" : "Nuboso" + }, { + "value" : "12n", + "periodo" : "01", + "descripcion" : "Poco nuboso" + }, { + "value" : "11n", + "periodo" : "02", + "descripcion" : "Despejado" + }, { + "value" : "11n", + "periodo" : "03", + "descripcion" : "Despejado" + }, { + "value" : "11n", + "periodo" : "04", + "descripcion" : "Despejado" + }, { + "value" : "11n", + "periodo" : "05", + "descripcion" : "Despejado" + }, { + "value" : "11n", + "periodo" : "06", + "descripcion" : "Despejado" + } ], + "precipitacion" : [ { + "value" : "0", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "0", + "periodo" : "02" + }, { + "value" : "0", + "periodo" : "03" + }, { + "value" : "0", + "periodo" : "04" + }, { + "value" : "0", + "periodo" : "05" + }, { + "value" : "0", + "periodo" : "06" + } ], + "probPrecipitacion" : [ { + "value" : "0", + "periodo" : "0107" + }, { + "value" : "", + "periodo" : "0713" + }, { + "value" : "", + "periodo" : "1319" + }, { + "value" : "", + "periodo" : "1901" + } ], + "probTormenta" : [ { + "value" : "0", + "periodo" : "0107" + }, { + "value" : "", + "periodo" : "0713" + }, { + "value" : "", + "periodo" : "1319" + }, { + "value" : "", + "periodo" : "1901" + } ], + "nieve" : [ { + "value" : "0", + "periodo" : "00" + }, { + "value" : "0", + "periodo" : "01" + }, { + "value" : "0", + "periodo" : "02" + }, { + "value" : "0", + "periodo" : "03" + }, { + "value" : "0", + "periodo" : "04" + }, { + "value" : "0", + "periodo" : "05" + }, { + "value" : "0", + "periodo" : "06" + } ], + "probNieve" : [ { + "value" : "0", + "periodo" : "0107" + }, { + "value" : "", + "periodo" : "0713" + }, { + "value" : "", + "periodo" : "1319" + }, { + "value" : "", + "periodo" : "1901" + } ], + "temperatura" : [ { + "value" : "-1", + "periodo" : "00" + }, { + "value" : "-1", + "periodo" : "01" + }, { + "value" : "-2", + "periodo" : "02" + }, { + "value" : "-2", + "periodo" : "03" + }, { + "value" : "-3", + "periodo" : "04" + }, { + "value" : "-4", + "periodo" : "05" + }, { + "value" : "-4", + "periodo" : "06" + } ], + "sensTermica" : [ { + "value" : "-6", + "periodo" : "00" + }, { + "value" : "-6", + "periodo" : "01" + }, { + "value" : "-6", + "periodo" : "02" + }, { + "value" : "-6", + "periodo" : "03" + }, { + "value" : "-7", + "periodo" : "04" + }, { + "value" : "-8", + "periodo" : "05" + }, { + "value" : "-8", + "periodo" : "06" + } ], + "humedadRelativa" : [ { + "value" : "81", + "periodo" : "00" + }, { + "value" : "79", + "periodo" : "01" + }, { + "value" : "77", + "periodo" : "02" + }, { + "value" : "76", + "periodo" : "03" + }, { + "value" : "76", + "periodo" : "04" + }, { + "value" : "76", + "periodo" : "05" + }, { + "value" : "78", + "periodo" : "06" + } ], + "vientoAndRachaMax" : [ { + "direccion" : [ "NE" ], + "velocidad" : [ "19" ], + "periodo" : "00" + }, { + "value" : "30", + "periodo" : "00" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "16" ], + "periodo" : "01" + }, { + "value" : "27", + "periodo" : "01" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "12" ], + "periodo" : "02" + }, { + "value" : "22", + "periodo" : "02" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "10" ], + "periodo" : "03" + }, { + "value" : "17", + "periodo" : "03" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "11" ], + "periodo" : "04" + }, { + "value" : "15", + "periodo" : "04" + }, { + "direccion" : [ "NE" ], + "velocidad" : [ "10" ], + "periodo" : "05" + }, { + "value" : "15", + "periodo" : "05" + }, { + "direccion" : [ "N" ], + "velocidad" : [ "10" ], + "periodo" : "06" + }, { + "value" : "15", + "periodo" : "06" + } ], + "fecha" : "2021-01-11T00:00:00", + "orto" : "08:36", + "ocaso" : "18:09" + } ] + }, + "id" : "28065", + "version" : "1.0" +} ] diff --git a/tests/fixtures/aemet/town-28065-forecast-hourly.json b/tests/fixtures/aemet/town-28065-forecast-hourly.json new file mode 100644 index 00000000000..2fbcaaeb33e --- /dev/null +++ b/tests/fixtures/aemet/town-28065-forecast-hourly.json @@ -0,0 +1,6 @@ +{ + "descripcion" : "exito", + "estado" : 200, + "datos" : "https://opendata.aemet.es/opendata/sh/18ca1886", + "metadatos" : "https://opendata.aemet.es/opendata/sh/93a7c63d" +} diff --git a/tests/fixtures/aemet/town-id28065.json b/tests/fixtures/aemet/town-id28065.json new file mode 100644 index 00000000000..342b163062c --- /dev/null +++ b/tests/fixtures/aemet/town-id28065.json @@ -0,0 +1,15 @@ +[ { + "latitud" : "40�18'14.535144\"", + "id_old" : "28325", + "url" : "getafe-id28065", + "latitud_dec" : "40.30403754", + "altitud" : "622", + "capital" : "Getafe", + "num_hab" : "173057", + "zona_comarcal" : "722802", + "destacada" : "1", + "nombre" : "Getafe", + "longitud_dec" : "-3.72935236", + "id" : "id28065", + "longitud" : "-3�43'45.668496\"" +} ] diff --git a/tests/fixtures/aemet/town-list.json b/tests/fixtures/aemet/town-list.json new file mode 100644 index 00000000000..d5ed23ef935 --- /dev/null +++ b/tests/fixtures/aemet/town-list.json @@ -0,0 +1,43 @@ +[ { + "latitud" : "40�18'14.535144\"", + "id_old" : "28325", + "url" : "getafe-id28065", + "latitud_dec" : "40.30403754", + "altitud" : "622", + "capital" : "Getafe", + "num_hab" : "173057", + "zona_comarcal" : "722802", + "destacada" : "1", + "nombre" : "Getafe", + "longitud_dec" : "-3.72935236", + "id" : "id28065", + "longitud" : "-3�43'45.668496\"" +}, { + "latitud" : "40�19'54.277752\"", + "id_old" : "28370", + "url" : "leganes-id28074", + "latitud_dec" : "40.33174382", + "altitud" : "667", + "capital" : "Legan�s", + "num_hab" : "186696", + "zona_comarcal" : "722802", + "destacada" : "1", + "nombre" : "Legan�s", + "longitud_dec" : "-3.76655557", + "id" : "id28074", + "longitud" : "-3�45'59.600052\"" +}, { + "latitud" : "40�24'30.282876\"", + "id_old" : "28001", + "url" : "madrid-id28079", + "latitud_dec" : "40.40841191", + "altitud" : "657", + "capital" : "Madrid", + "num_hab" : "3165235", + "zona_comarcal" : "722802", + "destacada" : "1", + "nombre" : "Madrid", + "longitud_dec" : "-3.68760088", + "id" : "id28079", + "longitud" : "-3�41'15.363168\"" +} ] diff --git a/tests/fixtures/mazda/get_vehicle_status.json b/tests/fixtures/mazda/get_vehicle_status.json new file mode 100644 index 00000000000..f170b222b31 --- /dev/null +++ b/tests/fixtures/mazda/get_vehicle_status.json @@ -0,0 +1,37 @@ +{ + "lastUpdatedTimestamp": "20210123143809", + "latitude": 1.234567, + "longitude": -2.345678, + "positionTimestamp": "20210123143808", + "fuelRemainingPercent": 87.0, + "fuelDistanceRemainingKm": 380.8, + "odometerKm": 2795.8, + "doors": { + "driverDoorOpen": false, + "passengerDoorOpen": false, + "rearLeftDoorOpen": false, + "rearRightDoorOpen": false, + "trunkOpen": false, + "hoodOpen": false, + "fuelLidOpen": false + }, + "doorLocks": { + "driverDoorUnlocked": false, + "passengerDoorUnlocked": false, + "rearLeftDoorUnlocked": false, + "rearRightDoorUnlocked": false + }, + "windows": { + "driverWindowOpen": false, + "passengerWindowOpen": false, + "rearLeftWindowOpen": false, + "rearRightWindowOpen": false + }, + "hazardLightsOn": false, + "tirePressure": { + "frontLeftTirePressurePsi": 35.0, + "frontRightTirePressurePsi": 35.0, + "rearLeftTirePressurePsi": 33.0, + "rearRightTirePressurePsi": 33.0 + } +} \ No newline at end of file diff --git a/tests/fixtures/mazda/get_vehicles.json b/tests/fixtures/mazda/get_vehicles.json new file mode 100644 index 00000000000..871eeb9d2ec --- /dev/null +++ b/tests/fixtures/mazda/get_vehicles.json @@ -0,0 +1,17 @@ +[ + { + "vin": "JM000000000000000", + "id": 12345, + "nickname": "My Mazda3", + "carlineCode": "M3S", + "carlineName": "MAZDA3 2.5 S SE AWD", + "modelYear": "2021", + "modelCode": "M3S SE XA", + "modelName": "W/ SELECT PKG AWD SDN", + "automaticTransmission": true, + "interiorColorCode": "BY3", + "interiorColorName": "BLACK", + "exteriorColorCode": "42M", + "exteriorColorName": "DEEP CRYSTAL BLUE MICA" + } +] \ No newline at end of file diff --git a/tests/fixtures/plex/plextv_shared_users.xml b/tests/fixtures/plex/plextv_shared_users.xml new file mode 100644 index 00000000000..9421bdfa17a --- /dev/null +++ b/tests/fixtures/plex/plextv_shared_users.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/fixtures/rest/configuration_empty.yaml b/tests/fixtures/rest/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/rest/configuration_invalid.notyaml b/tests/fixtures/rest/configuration_invalid.notyaml new file mode 100644 index 00000000000..548d8bcf5a0 --- /dev/null +++ b/tests/fixtures/rest/configuration_invalid.notyaml @@ -0,0 +1,2 @@ +*!* NOT YAML + diff --git a/tests/fixtures/rest/configuration_top_level.yaml b/tests/fixtures/rest/configuration_top_level.yaml new file mode 100644 index 00000000000..df27e160117 --- /dev/null +++ b/tests/fixtures/rest/configuration_top_level.yaml @@ -0,0 +1,12 @@ +rest: + - method: GET + resource: "http://localhost" + sensor: + name: fallover + +sensor: + - platform: rest + resource: "http://localhost" + method: GET + name: rollout + diff --git a/tests/fixtures/tado/zone_default_overlay.json b/tests/fixtures/tado/zone_default_overlay.json new file mode 100644 index 00000000000..092b2b25d4d --- /dev/null +++ b/tests/fixtures/tado/zone_default_overlay.json @@ -0,0 +1,5 @@ +{ + "terminationCondition": { + "type": "MANUAL" + } +} diff --git a/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json b/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json new file mode 100644 index 00000000000..36db78faace --- /dev/null +++ b/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json @@ -0,0 +1,1248 @@ +{ + "nodeId": 102, + "index": 0, + "installerIcon": 1792, + "userIcon": 1792, + "status": 4, + "ready": true, + "deviceClass": { + "basic": {"key": 4, "label": "Routing Slave"}, + "generic": {"key": 16, "label":"Binary Switch"}, + "specific": {"key": 1, "label":"Binary Power Switch"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": true, + "version": 4, + "isBeaming": true, + "manufacturerId": 134, + "productId": 96, + "productType": 3, + "firmwareVersion": "1.1", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "deviceConfig": { + "manufacturerId": 134, + "manufacturer": "AEON Labs", + "label": "ZW096", + "description": "Smart Switch 6", + "devices": [ + { "productType": "0x0003", "productId": "0x0060" }, + { "productType": "0x0103", "productId": "0x0060" }, + { "productType": "0x0203", "productId": "0x0060" }, + { "productType": "0x1d03", "productId": "0x0060" } + ], + "firmwareVersion": { "min": "0.0", "max": "255.255" }, + "associations": {}, + "paramInformation": { "_map": {} } + }, + "label": "ZW096", + "neighbors": [1, 63, 90, 117], + "interviewAttempts": 1, + "interviewStage": 7, + "endpoints": [ + { + "nodeId": 102, + "index": 0, + "installerIcon": 1792, + "userIcon": 1792 + } + ], + "commandClasses": [], + "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" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 99, + "label": "Target value" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { "switchType": 2 } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { "switchType": 2 } + } + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [kWh]", + "unit": "kWh", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } + }, + "value": 659.813 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "previousValue", + "propertyKey": 65537, + "propertyName": "previousValue", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [kWh] (prev. value)", + "unit": "kWh", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } + }, + "value": 659.813 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "deltaTime", + "propertyKey": 65537, + "propertyName": "deltaTime", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [kWh] (prev. time delta)", + "unit": "s", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } + }, + "value": 1200 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [W]", + "unit": "W", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "deltaTime", + "propertyKey": 66049, + "propertyName": "deltaTime", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [W] (prev. time delta)", + "unit": "s", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [V]", + "unit": "V", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 4 } + }, + "value": 229.935 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "deltaTime", + "propertyKey": 66561, + "propertyName": "deltaTime", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [V] (prev. time delta)", + "unit": "s", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 4 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [A]", + "unit": "A", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 5 } + }, + "value": 9.699 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "deltaTime", + "propertyKey": 66817, + "propertyName": "deltaTime", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [A] (prev. time delta)", + "unit": "s", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 5 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values" + } + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "previousValue", + "propertyKey": 66049, + "propertyName": "previousValue", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [W] (prev. value)", + "unit": "W", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } + } + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "previousValue", + "propertyKey": 66561, + "propertyName": "previousValue", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [V] (prev. value)", + "unit": "V", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 4 } + } + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "previousValue", + "propertyKey": 66817, + "propertyName": "previousValue", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [A] (prev. value)", + "unit": "A", + "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 5 } + } + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Remaining duration" + } + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Current value (Red)", + "description": "The current value of the Red color." + }, + "value": 27 + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "minLength": 6, + "maxLength": 7, + "label": "RGB Color" + }, + "value": "1b141b" + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Current value (Green)", + "description": "The current value of the Green color." + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Current value (Blue)", + "description": "The current value of the Blue color." + }, + "value": 27 + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Target value (Red)", + "description": "The target value of the Red color." + } + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Target value (Green)", + "description": "The target value of the Green color." + } + }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Target value (Blue)", + "description": "The target value of the Blue color." + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Current overload protection enable", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { "0": "disabled", "1": "enabled" }, + "label": "Current overload protection enable", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Output load after re-power", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 2, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "last status", + "1": "always on", + "2": "always off" + }, + "label": "Output load after re-power", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 80, + "propertyName": "Enable send to associated devices", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 2, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "nothing", + "1": "hail CC", + "2": "basic CC report" + }, + "label": "Enable send to associated devices", + "description": "Enable to send notifications to Group 1", + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 81, + "propertyName": "Configure LED state", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 2, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "LED follows load", + "1": "LED follows load for 5 seconds", + "2": "Night light mode" + }, + "label": "Configure LED state", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 90, + "propertyName": "Enable items 91 and 92", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { "0": "disabled", "1": "enabled" }, + "label": "Enable items 91 and 92", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 91, + "propertyName": "Wattage Threshold", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 2, + "min": 0, + "max": 60000, + "default": 25, + "format": 1, + "allowManualEntry": true, + "label": "Wattage Threshold", + "description": "minimum change in wattage to trigger", + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 92, + "propertyName": "Wattage Percent Change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 100, + "default": 5, + "format": 0, + "allowManualEntry": true, + "label": "Wattage Percent Change", + "description": "minimum change in wattage percent", + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 101, + "propertyName": "Values to send to group 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 15, + "default": 4, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Nothing", + "1": "Voltage", + "2": "Current", + "4": "Wattage", + "8": "kWh", + "15": "All Values" + }, + "label": "Values to send to group 1", + "description": "Which reports need to send in Report group 1", + "isFromConfig": true + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 102, + "propertyName": "Values to send to group 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 15, + "default": 8, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Nothing", + "1": "Voltage", + "2": "Current", + "4": "Wattage", + "8": "kWh", + "15": "All Values" + }, + "label": "Values to send to group 2", + "description": "Which reports need to send in Report group 2", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 103, + "propertyName": "Values to send to group 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 15, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Nothing", + "1": "Voltage", + "2": "Current", + "4": "Wattage", + "8": "kWh", + "15": "All Values" + }, + "label": "Values to send to group 3", + "description": "Which reports need to send in Report group 3", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 111, + "propertyName": "Time interval for sending to group 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 2147483647, + "default": 3, + "format": 0, + "allowManualEntry": true, + "label": "Time interval for sending to group 1", + "description": "Group 1 automatic update interval", + "isFromConfig": true + }, + "value": 1200 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 112, + "propertyName": "Time interval for sending to group 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 2147483647, + "default": 600, + "format": 0, + "allowManualEntry": true, + "label": "Time interval for sending to group 2", + "description": "Group 2 automatic update interval", + "isFromConfig": true + }, + "value": 120 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 113, + "propertyName": "Time interval for sending to group 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 2147483647, + "default": 600, + "format": 0, + "allowManualEntry": true, + "label": "Time interval for sending to group 3", + "description": "Group 3 automatic update interval", + "isFromConfig": true + }, + "value": 65460 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 252, + "propertyName": "Configuration Locked", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { "0": "disabled", "1": "enabled" }, + "label": "Configuration Locked", + "description": "Enable/disable Configuration Locked (0 =disable, 1 = enable).", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 83, + "propertyKey": 255, + "propertyName": "Blue night light color value", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 255, + "default": 221, + "format": 0, + "allowManualEntry": true, + "label": "Blue night light color value", + "isFromConfig": true + }, + "value": 27 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 83, + "propertyKey": 65280, + "propertyName": "Green night light color value", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 255, + "default": 160, + "format": 0, + "allowManualEntry": true, + "label": "Green night light color value", + "isFromConfig": true + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 83, + "propertyKey": 16711680, + "propertyName": "Red night light color value", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 255, + "default": 221, + "format": 0, + "allowManualEntry": true, + "label": "Red night light color value", + "description": "Configure the RGB value when it is in Night light mode", + "isFromConfig": true + }, + "value": 27 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyKey": 255, + "propertyName": "Green brightness in energy mode (%)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 100, + "default": 50, + "format": 0, + "allowManualEntry": true, + "label": "Green brightness in energy mode (%)", + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyKey": 65280, + "propertyName": "Yellow brightness in energy mode (%)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 100, + "default": 50, + "format": 0, + "allowManualEntry": true, + "label": "Yellow brightness in energy mode (%)", + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyKey": 16711680, + "propertyName": "Red brightness in energy mode (%)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 3, + "min": 0, + "max": 100, + "default": 50, + "format": 0, + "allowManualEntry": true, + "label": "Red brightness in energy mode (%)", + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "RGB LED color testing", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 0, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "RGB LED color testing", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 100, + "propertyName": "Set 101\u2010103 to default.", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { "0": "False", "1": "True" }, + "label": "Set 101\u2010103 to default.", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 110, + "propertyName": "Set 111\u2010113 to default.", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { "0": "False", "1": "True" }, + "label": "Set 111\u2010113 to default.", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 255, + "propertyName": "RESET", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "RESET", + "description": "Reset the device to defaults", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 134 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 96 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.54" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ] + } \ No newline at end of file diff --git a/tests/fixtures/zwave_js/aeotec_radiator_thermostat_state.json b/tests/fixtures/zwave_js/aeotec_radiator_thermostat_state.json new file mode 100644 index 00000000000..27c3f991d33 --- /dev/null +++ b/tests/fixtures/zwave_js/aeotec_radiator_thermostat_state.json @@ -0,0 +1,620 @@ +{ + "nodeId": 4, + "index": 0, + "installerIcon": 4608, + "userIcon": 4608, + "status": 4, + "ready": true, + "deviceClass": { + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] + }, + "isListening": false, + "isFrequentListening": true, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 881, + "productId": 21, + "productType": 2, + "firmwareVersion": "0.16", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 7, + "deviceConfig": { + "manufacturerId": 881, + "manufacturer": "Aeotec Ltd.", + "label": "Radiator Thermostat", + "description": "Thermostat - HVAC", + "devices": [{ "productType": "0x0002", "productId": "0x0015" }], + "firmwareVersion": { "min": "0.0", "max": "255.255" }, + "paramInformation": { "_map": {} } + }, + "label": "Radiator Thermostat", + "neighbors": [6, 7, 45, 67], + "interviewAttempts": 1, + "endpoints": [ + { "nodeId": 4, "index": 0, "installerIcon": 4608, "userIcon": 4608 } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 99, + "label": "Target value" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 75 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { "switchType": 2 } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { "switchType": 2 } + } + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "unit": "\u00b0C", + "label": "Air temperature", + "ccSpecific": { "sensorType": 1, "scale": 0 } + }, + "value": 19.37 + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": { + "0": "Off", + "1": "Heat", + "11": "Energy heat", + "15": "Full power" + } + }, + "value": 31 + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "manufacturerData", + "propertyName": "manufacturerData", + "ccVersion": 3, + "metadata": { "type": "any", "readable": true, "writeable": true } + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 8, + "max": 28, + "unit": "\u00b0C", + "ccSpecific": { "setpointType": 1 } + }, + "value": 24 + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyName": "setpoint", + "propertyKeyName": "Energy Save Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 8, + "max": 28, + "unit": "\u00b0C", + "ccSpecific": { "setpointType": 11 } + }, + "value": 18 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Invert LCD orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Normal orientation", + "1": "LCD content inverted" + }, + "label": "Invert LCD orientation", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "LCD Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 30, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "LCD Timeout", + "description": "LCD Timeout in seconds", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Backlight", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Backlight disabled", + "1": "Backlight enabled" + }, + "label": "Backlight", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Battery report", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Battery reporting disabled", + "1": "Battery reporting enabled" + }, + "label": "Battery report", + "description": "Battery reporting", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Measured Temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 50, + "default": 5, + "format": 0, + "allowManualEntry": true, + "label": "Measured Temperature", + "description": "Measured Temperature report. Reporting Delta in 1/10 Celsius. '0' to disable reporting.", + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Valve position", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 100, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Valve position", + "description": "Valve position report. Reporting delta in percent. '0' to disable reporting.", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Window open detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 3, + "default": 2, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Detection Disabled", + "1": "Sensitivity low", + "2": "Sensitivity medium", + "3": "Sensitivity high" + }, + "label": "Window open detection", + "description": "Control 'Window open detection' sensitivity", + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Temperature Offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": -128, + "max": 50, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Temperature Offset", + "description": "Measured Temperature offset", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyName": "Power Management", + "propertyKeyName": "Battery maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Battery maintenance status", + "states": { + "0": "idle", + "10": "Replace battery soon", + "11": "Replace battery now" + }, + "ccSpecific": { "notificationType": 8 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Hardware status", + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "ccSpecific": { "notificationType": 9 } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 881 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 21 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 100, + "unit": "%", + "label": "Battery level" + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.61" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["0.16"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ] + } \ No newline at end of file diff --git a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json index b7c422121c9..64bfecfb20b 100644 --- a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json +++ b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json @@ -6,14 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Multilevel Switch", - "specific": "Multilevel Power Switch", - "mandatorySupportedCCs": [ - "Basic", - "Multilevel Switch", - "All Switch" - ], + "basic": {"key": 2, "label": "Static Controller"}, + "generic": {"key": 17, "label":"Multilevel Switch"}, + "specific": {"key": 1, "label":"Multilevel Power Switch"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -67,6 +63,7 @@ "userIcon": 1536 } ], + "commandClasses": [], "values": [ { "commandClassName": "Multilevel Switch", diff --git a/tests/fixtures/zwave_js/chain_actuator_zws12_state.json b/tests/fixtures/zwave_js/chain_actuator_zws12_state.json index dbae35e04d0..cf7adddc21e 100644 --- a/tests/fixtures/zwave_js/chain_actuator_zws12_state.json +++ b/tests/fixtures/zwave_js/chain_actuator_zws12_state.json @@ -6,16 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Multilevel Switch", - "specific": "Motor Control Class C", - "mandatorySupportedCCs": [ - "Basic", - "Multilevel Switch", - "Binary Switch", - "Manufacturer Specific", - "Version" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 17, "label":"Multilevel Switch"}, + "specific": {"key": 7, "label":"Motor Control Class C"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -52,6 +46,7 @@ "endpoints": [ { "nodeId": 6, "index": 0, "installerIcon": 6656, "userIcon": 6656 } ], + "commandClasses": [], "values": [ { "commandClassName": "Multilevel Switch", diff --git a/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json b/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json index e218d3b6a0e..90410998597 100644 --- a/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json +++ b/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json @@ -4,15 +4,10 @@ "status": 1, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Thermostat", - "specific": "Setpoint Thermostat", - "mandatorySupportedCCs": [ - "Manufacturer Specific", - "Multi Command", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 4, "label":"Setpoint Thermostat"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": false, @@ -77,6 +72,7 @@ "index": 0 } ], + "commandClasses": [], "values": [ { "endpoint": 0, diff --git a/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json b/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json index 066811c7374..0dc040c6cb2 100644 --- a/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json +++ b/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json @@ -6,16 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -116,6 +110,7 @@ "userIcon": 3329 } ], + "commandClasses": [], "values": [ { "endpoint": 0, diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json index ea38dfd9d6b..fcdd57e981b 100644 --- a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json +++ b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json @@ -6,16 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -63,6 +57,7 @@ "userIcon": 3333 } ], + "commandClasses": [], "values": [ { "commandClassName": "Manufacturer Specific", diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json index 77a68aafde1..34df415301e 100644 --- a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json +++ b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json @@ -6,16 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -63,6 +57,7 @@ }, { "nodeId": 13, "index": 2 } ], + "commandClasses": [], "values": [ { "commandClassName": "Manufacturer Specific", @@ -594,6 +589,52 @@ "propertyName": "manufacturerData", "metadata": { "type": "any", "readable": true, "writeable": true } }, + { + "endpoint": 1, + "commandClass": 68, + "commandClassName": "Thermostat Fan Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "states": { "0": "Auto low", "1": "Low" }, + "label": "Thermostat fan mode" + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 69, + "commandClassName": "Thermostat Fan State", + "property": "state", + "propertyName": "state", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "states": { + "0": "Idle / off", + "1": "Running / running low", + "2": "Running high", + "3": "Running medium", + "4": "Circulation mode", + "5": "Humidity circulation mode", + "6": "Right - left circulation mode", + "7": "Up - down circulation mode", + "8": "Quiet circulation mode" + }, + "label": "Thermostat fan state" + }, + "value": 0 + }, { "commandClassName": "Thermostat Operating State", "commandClass": 66, diff --git a/tests/fixtures/zwave_js/cover_iblinds_v2_state.json b/tests/fixtures/zwave_js/cover_iblinds_v2_state.json new file mode 100644 index 00000000000..35ce70f617a --- /dev/null +++ b/tests/fixtures/zwave_js/cover_iblinds_v2_state.json @@ -0,0 +1,357 @@ +{ + "nodeId": 54, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "status": 4, + "ready": true, + "deviceClass": { + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 17, "label":"Routing Slave"}, + "specific": {"key": 0, "label":"Unused"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] + }, + "isListening": false, + "isFrequentListening": true, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 647, + "productId": 13, + "productType": 3, + "firmwareVersion": "1.65", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 7, + "deviceConfig": { + "manufacturerId": 647, + "manufacturer": "HAB Home Intelligence, LLC", + "label": "IB2.0", + "description": "Window Blind Controller", + "devices": [ + { + "productType": "0x0003", + "productId": "0x000d" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "IB2.0", + "neighbors": [ + 1, + 2, + 3, + 7, + 8, + 11, + 15, + 18, + 19, + 22, + 26, + 27, + 44, + 52 + ], + "interviewAttempts": 1, + "interviewStage": 7, + "endpoints": [ + { + "nodeId": 54, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400 + } + ], + "commandClasses": [], + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 99, + "label": "Target value" + }, + "value": 99 + }, + { + "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, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 30 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 647 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 13 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 100, + "unit": "%", + "label": "Battery level" + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.33" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "1.65" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ] +} diff --git a/tests/fixtures/zwave_js/cover_zw062_state.json b/tests/fixtures/zwave_js/cover_zw062_state.json new file mode 100644 index 00000000000..9e7b05adc34 --- /dev/null +++ b/tests/fixtures/zwave_js/cover_zw062_state.json @@ -0,0 +1,921 @@ +{ + "nodeId": 12, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "status": 4, + "ready": true, + "deviceClass": { + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 64, "label":"Entry Control"}, + "specific": {"key": 7, "label":"Secure Barrier Add-on"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": true, + "version": 4, + "isBeaming": true, + "manufacturerId": 134, + "productId": 62, + "productType": 259, + "firmwareVersion": "1.12", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "deviceConfig": { + "manufacturerId": 134, + "manufacturer": "AEON Labs", + "label": "ZW062", + "description": "Aeon Labs Garage Door Controller Gen5", + "devices": [ + { + "productType": "0x0003", + "productId": "0x003e" + }, + { + "productType": "0x0103", + "productId": "0x003e" + }, + { + "productType": "0x0203", + "productId": "0x003e" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + } + }, + "label": "ZW062", + "neighbors": [ + 1, + 8, + 11, + 15, + 19, + 21, + 22, + 24, + 25, + 26, + 27, + 29 + ], + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 12, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680 + } + ], + "commandClasses": [], + "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": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + } + }, + { + "endpoint": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "property": "currentState", + "propertyName": "currentState", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Current Barrier State", + "states": { + "0": "Closed", + "252": "Closing", + "253": "Stopped", + "254": "Opening", + "255": "Open" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "property": "position", + "propertyName": "position", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 100, + "label": "Barrier Position", + "unit": "%" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "property": "signalingState", + "propertyKey": 1, + "propertyName": "signalingState", + "propertyKeyName": "1", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Signaling State (Audible)", + "states": { + "0": "Off", + "255": "On" + } + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "property": "signalingState", + "propertyKey": 2, + "propertyName": "signalingState", + "propertyKeyName": "2", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Signaling State (Visual)", + "states": { + "0": "Off", + "255": "On" + } + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 102, + "commandClassName": "Barrier Operator", + "property": "targetState", + "propertyName": "targetState", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Target Barrier State", + "states": { + "0": "Closed", + "255": "Open" + } + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Startup ringtone", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 100, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Startup ringtone", + "description": "Configure the default startup ringtone", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Calibration timout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 2, + "min": 1, + "max": 255, + "default": 60, + "format": 0, + "allowManualEntry": true, + "label": "Calibration timout", + "description": "Set the timeout of all calibration steps for the Sensor.", + "isFromConfig": true + }, + "value": 13 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Number of alarm musics", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "valueSize": 1, + "min": 1, + "max": 100, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Number of alarm musics", + "description": "Get the number of alarm musics", + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "Unknown state alarm mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 0, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Unknown state alarm mode", + "description": "Configuration alarm mode when the garage door is in \"unknown\" state", + "isFromConfig": true + }, + "value": 100927488 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Closed alarm mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 0, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Closed alarm mode", + "description": "Configure the alarm mode when the garage door is in closed position.", + "isFromConfig": true + }, + "value": 33883392 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyName": "Tamper switch configuration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": true, + "label": "Tamper switch configuration", + "description": "Configuration report for the tamper switch State", + "isFromConfig": true + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyName": "Battery state", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "valueSize": 1, + "min": 0, + "max": 16, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Battery state", + "description": "Configuration report for the battery state of Sensor", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 45, + "propertyName": "Temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "valueSize": 2, + "min": 0, + "max": 500, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Temperature", + "description": "Get the environment temperature", + "isFromConfig": true + }, + "value": 550 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyName": "Button definition", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Mode 0", + "1": "Mode 1" + }, + "label": "Button definition", + "description": "Define the function of Button- or Button+.", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 80, + "propertyName": "Door state change report type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 2, + "default": 2, + "format": 0, + "allowManualEntry": false, + "states": { + "1": "Send hail CC", + "2": "Send barrier operator report CC" + }, + "label": "Door state change report type", + "description": "Configure the door state change report type", + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 241, + "propertyName": "Pair the Sensor", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 1431655681, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Stop sensor pairing", + "1431655681": "Start sensor pairing" + }, + "label": "Pair the Sensor", + "description": "Pair the Sensor with Garage Door Controller", + "isFromConfig": true + }, + "value": 33554943 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 252, + "propertyName": "Lock Configuration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Configuration enabled", + "1": "Configuration disabled (locked)" + }, + "label": "Lock Configuration", + "description": "Enable/disable configuration", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyKey": 255, + "propertyName": "Disable opening alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Disable alarm prompt", + "1": "Enable alarm prompt" + }, + "label": "Disable opening alarm", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyKey": 65280, + "propertyName": "Opening alarm volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 10, + "default": 8, + "format": 0, + "allowManualEntry": true, + "label": "Opening alarm volume", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyKey": 16711680, + "propertyName": "Opening alarm choice", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 4, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Opening alarm choice", + "description": "Alarm mode when the garage door is opening", + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyKey": 251658240, + "propertyName": "Opening alarm LED mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 10, + "default": 10, + "format": 0, + "allowManualEntry": true, + "label": "Opening alarm LED mode", + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 255, + "propertyName": "Disable closing alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Disable alarm prompt", + "1": "Enable alarm prompt" + }, + "label": "Disable closing alarm", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 65280, + "propertyName": "Closing alarm volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 10, + "default": 8, + "format": 0, + "allowManualEntry": true, + "label": "Closing alarm volume", + "isFromConfig": true + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 16711680, + "propertyName": "Closing alarm choice", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 4, + "default": 2, + "format": 0, + "allowManualEntry": true, + "label": "Closing alarm choice", + "description": "Alarm mode when the garage door is closing", + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 251658240, + "propertyName": "Closing alarm LED mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 1, + "max": 10, + "default": 6, + "format": 0, + "allowManualEntry": true, + "label": "Closing alarm LED mode", + "isFromConfig": true + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "Sensor Calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Calibration not active", + "1": "Begin calibration" + }, + "label": "Sensor Calibration", + "description": "Perform Sensor Calibration", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Play or Pause ringtone", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 255, + "default": 1, + "format": 1, + "allowManualEntry": true, + "label": "Play or Pause ringtone", + "description": "Start playing or Stop playing the ringtone", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Ringtone test volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 10, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Ringtone test volume", + "description": "Set volume for test of ringtone", + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Alarm Type" + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Alarm Level" + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 134 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 259 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 62 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "3.99" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "1.12" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ] +} diff --git a/tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json b/tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json index 0f2f45d01e3..b11d2bfd180 100644 --- a/tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json +++ b/tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json @@ -6,14 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Multilevel Switch", - "specific": "Multilevel Power Switch", - "mandatorySupportedCCs": [ - "Basic", - "Multilevel Switch", - "All Switch" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 17, "label":"Routing Slave"}, + "specific": {"key": 1, "label":"Multilevel Power Switch"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -31,7 +27,7 @@ "nodeType": 0, "roleType": 5, "name": "AllLoadDimmer", - "location": "", + "location": "LivingRoom", "deviceConfig": { "manufacturerId": 26, "manufacturer": "Eaton", @@ -74,6 +70,7 @@ "userIcon": 1536 } ], + "commandClasses": [], "values": [ { "commandClassName": "Multilevel Switch", diff --git a/tests/fixtures/zwave_js/ecolink_door_sensor_state.json b/tests/fixtures/zwave_js/ecolink_door_sensor_state.json index bd5f2c6b466..9c2befdf5e8 100644 --- a/tests/fixtures/zwave_js/ecolink_door_sensor_state.json +++ b/tests/fixtures/zwave_js/ecolink_door_sensor_state.json @@ -4,16 +4,11 @@ "status": 1, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Binary Sensor", - "specific": "Routing Binary Sensor", - "mandatorySupportedCCs": [ - "Basic", - "Binary Sensor" - ], - "mandatoryControlCCs": [ - - ] + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 32, "label":"Binary Sensor"}, + "specific": {"key": 1, "label":"Routing Binary Sensor"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] }, "isListening": false, "isFrequentListening": false, @@ -61,6 +56,7 @@ "index": 0 } ], + "commandClasses": [], "values": [ { "commandClassName": "Basic", diff --git a/tests/fixtures/zwave_js/fan_ge_12730_state.json b/tests/fixtures/zwave_js/fan_ge_12730_state.json new file mode 100644 index 00000000000..b6cf59b4226 --- /dev/null +++ b/tests/fixtures/zwave_js/fan_ge_12730_state.json @@ -0,0 +1,431 @@ +{ + "nodeId": 24, + "index": 0, + "status": 4, + "ready": true, + "deviceClass": { + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 17, "label":"Multilevel Switch"}, + "specific": {"key": 1, "label":"Multilevel Power Switch"}, + "mandatorySupportedCCs": [], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 99, + "productId": 12340, + "productType": 18756, + "firmwareVersion": "3.10", + "deviceConfig": { + "manufacturerId": 99, + "manufacturer": "GE/Jasco", + "label": "12730 / ZW4002", + "description": "In-Wall Smart Fan Control", + "devices": [ + { + "productType": "0x4944", + "productId": "0x3034" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "12730 / ZW4002", + "neighbors": [ + 1, + 12 + ], + "interviewAttempts": 1, + "interviewStage": 7, + "endpoints": [ + { + "nodeId": 24, + "index": 0 + } + ], + "commandClasses": [], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 99, + "label": "Target value" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Light", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 2, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "LED on when light off", + "1": "LED on when light on", + "2": "LED always off" + }, + "label": "LED Light", + "description": "Sets when the LED on the switch is lit.", + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Invert Switch", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "No", + "1": "Yes" + }, + "label": "Invert Switch", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Dim Rate Steps (Z-Wave Command)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 99, + "default": 1, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Steps (Z-Wave Command)", + "description": "Number of steps or levels", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Dim Rate Timing (Z-Wave)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 2, + "min": 1, + "max": 255, + "default": 3, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Timing (Z-Wave)", + "description": "Timing of steps or levels", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Dim Rate Steps (Manual)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 99, + "default": 1, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Steps (Manual)", + "description": "Number of steps or levels", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Dim Rate Timing (Manual)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 2, + "min": 1, + "max": 255, + "default": 3, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Timing (Manual)", + "description": "Timing of steps", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Dim Rate Steps (All-On/All-Off)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 99, + "default": 1, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Steps (All-On/All-Off)", + "description": "Number of steps or levels", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Dim Rate Timing (All-On/All-Off)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 2, + "min": 1, + "max": 255, + "default": 3, + "format": 1, + "allowManualEntry": true, + "label": "Dim Rate Timing (All-On/All-Off)", + "description": "Timing of steps or levels", + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 12340 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "3.67" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "3.10" + ] + } + ] +} diff --git a/tests/fixtures/zwave_js/hank_binary_switch_state.json b/tests/fixtures/zwave_js/hank_binary_switch_state.json index 0c629b3cf99..e5f739d63a5 100644 --- a/tests/fixtures/zwave_js/hank_binary_switch_state.json +++ b/tests/fixtures/zwave_js/hank_binary_switch_state.json @@ -6,14 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Binary Switch", - "specific": "Binary Power Switch", - "mandatorySupportedCCs": [ - "Basic", - "Binary Switch", - "All Switch" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 16, "label":"Binary Switch"}, + "specific": {"key": 1, "label":"Binary Power Switch"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -67,6 +63,7 @@ "userIcon": 1792 } ], + "commandClasses": [], "values": [ { "commandClassName": "Binary Switch", diff --git a/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json b/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json index fe5550a5424..74467664955 100644 --- a/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json +++ b/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json @@ -6,13 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Multilevel Switch", - "specific": "Fan Switch", - "mandatorySupportedCCs": [ - "Basic", - "Multilevel Switch" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 17, "label":"Multilevel Switch"}, + "specific": {"key": 8, "label":"Fan Switch"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -87,6 +84,7 @@ "userIcon": 1024 } ], + "commandClasses": [], "values": [ { "commandClassName": "Multilevel Switch", diff --git a/tests/fixtures/zwave_js/lock_august_asl03_state.json b/tests/fixtures/zwave_js/lock_august_asl03_state.json index b6d44341853..2b218cd915b 100644 --- a/tests/fixtures/zwave_js/lock_august_asl03_state.json +++ b/tests/fixtures/zwave_js/lock_august_asl03_state.json @@ -6,17 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Routing Slave", - "generic": "Entry Control", - "specific": "Secure Keypad Door Lock", - "mandatorySupportedCCs": [ - "Basic", - "Door Lock", - "User Code", - "Manufacturer Specific", - "Security", - "Version" - ], + "basic": {"key": 4, "label":"Routing Slave"}, + "generic": {"key": 64, "label":"Entry Control"}, + "specific": {"key": 3, "label":"Secure Keypad Door Lock"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": false, @@ -73,6 +66,7 @@ "userIcon": 768 } ], + "commandClasses": [], "values": [ { "commandClassName": "Door Lock", diff --git a/tests/fixtures/zwave_js/lock_schlage_be469_state.json b/tests/fixtures/zwave_js/lock_schlage_be469_state.json index af1fc92a206..be1ddb9c3f0 100644 --- a/tests/fixtures/zwave_js/lock_schlage_be469_state.json +++ b/tests/fixtures/zwave_js/lock_schlage_be469_state.json @@ -4,17 +4,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Entry Control", - "specific": "Secure Keypad Door Lock", - "mandatorySupportedCCs": [ - "Basic", - "Door Lock", - "User Code", - "Manufacturer Specific", - "Security", - "Version" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 64, "label":"Entry Control"}, + "specific": {"key": 3, "label":"Secure Keypad Door Lock"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": false, @@ -57,6 +50,7 @@ "index": 0 } ], + "commandClasses": [], "values": [ { "commandClassName": "Door Lock", diff --git a/tests/fixtures/zwave_js/multisensor_6_state.json b/tests/fixtures/zwave_js/multisensor_6_state.json index 3c508ffd3ff..131a5aa026f 100644 --- a/tests/fixtures/zwave_js/multisensor_6_state.json +++ b/tests/fixtures/zwave_js/multisensor_6_state.json @@ -6,13 +6,10 @@ "status": 1, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Multilevel Sensor", - "specific": "Routing Multilevel Sensor", - "mandatorySupportedCCs": [ - "Basic", - "Multilevel Sensor" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 21, "label":"Multilevel Sensor"}, + "specific": {"key": 1, "label":"Routing Multilevel Sensor"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": true, @@ -70,6 +67,7 @@ "userIcon": 3079 } ], + "commandClasses": [], "values": [ { "commandClassName": "Basic", diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json index d778f77ce24..60078100caf 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json @@ -7,16 +7,10 @@ "status": 0, "ready": false, "deviceClass": { - "basic": "Static Controller", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "neighbors": [], @@ -27,6 +21,7 @@ "index": 0 } ], + "commandClasses": [], "values": [ { "commandClassName": "Basic", diff --git a/tests/fixtures/zwave_js/nortek_thermostat_removed_event.json b/tests/fixtures/zwave_js/nortek_thermostat_removed_event.json index ed25a650543..01bad6c4a8f 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_removed_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_removed_event.json @@ -7,16 +7,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": false, @@ -67,6 +61,7 @@ "index": 0 } ], + "commandClasses": [], "values": [ { "commandClassName": "Manufacturer Specific", diff --git a/tests/fixtures/zwave_js/nortek_thermostat_state.json b/tests/fixtures/zwave_js/nortek_thermostat_state.json index 62a08999cda..4e6ca17e013 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_state.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_state.json @@ -6,16 +6,10 @@ "status": 4, "ready": true, "deviceClass": { - "basic": "Static Controller", - "generic": "Thermostat", - "specific": "Thermostat General V2", - "mandatorySupportedCCs": [ - "Basic", - "Manufacturer Specific", - "Thermostat Mode", - "Thermostat Setpoint", - "Version" - ], + "basic": {"key": 2, "label":"Static Controller"}, + "generic": {"key": 8, "label":"Thermostat"}, + "specific": {"key": 6, "label":"Thermostat General V2"}, + "mandatorySupportedCCs": [], "mandatoryControlCCs": [] }, "isListening": false, @@ -75,6 +69,7 @@ "userIcon": 4608 } ], + "commandClasses": [], "values": [ { "commandClassName": "Manufacturer Specific", diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py new file mode 100644 index 00000000000..f99ee911a69 --- /dev/null +++ b/tests/hassfest/test_version.py @@ -0,0 +1,47 @@ +"""Tests for hassfest version.""" +import pytest +import voluptuous as vol + +from script.hassfest.manifest import ( + CUSTOM_INTEGRATION_MANIFEST_SCHEMA, + validate_version, +) +from script.hassfest.model import Integration + + +@pytest.fixture +def integration(): + """Fixture for hassfest integration model.""" + integration = Integration("") + integration.manifest = { + "domain": "test", + "documentation": "https://example.com", + "name": "test", + "codeowners": ["@awesome"], + } + return integration + + +def test_validate_version_no_key(integration: Integration): + """Test validate version with no key.""" + validate_version(integration) + assert ( + "No 'version' key in the manifest file. This will cause a future version of Home Assistant to block this integration." + in [x.error for x in integration.errors] + ) + + +def test_validate_custom_integration_manifest(integration: Integration): + """Test validate custom integration manifest.""" + + with pytest.raises(vol.Invalid): + integration.manifest["version"] = "lorem_ipsum" + CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) + + with pytest.raises(vol.Invalid): + integration.manifest["version"] = None + CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) + + integration.manifest["version"] = "1" + schema = CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) + assert schema["version"] == "1" diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index ec008dde7da..7dca029987e 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,7 +1,4 @@ """Tests for the Area Registry.""" -import asyncio -import unittest.mock - import pytest from homeassistant.core import callback @@ -61,7 +58,7 @@ async def test_create_area_with_name_already_in_use(hass, registry, update_event with pytest.raises(ValueError) as e_info: area2 = registry.async_create("mock") assert area1 != area2 - assert e_info == "Name is already in use" + assert e_info == "The name mock 2 (mock2) is already in use" await hass.async_block_till_done() @@ -84,7 +81,7 @@ async def test_delete_area(hass, registry, update_events): """Make sure that we can delete an area.""" area = registry.async_create("mock") - await registry.async_delete(area.id) + registry.async_delete(area.id) assert not registry.areas @@ -136,6 +133,18 @@ async def test_update_area_with_same_name(registry): assert len(registry.areas) == 1 +async def test_update_area_with_same_name_change_case(registry): + """Make sure that we can reapply the same name with a different case to the area.""" + area = registry.async_create("mock") + + updated_area = registry.async_update(area.id, name="Mock") + + assert updated_area.name == "Mock" + assert updated_area.id == area.id + assert updated_area.normalized_name == area.normalized_name + assert len(registry.areas) == 1 + + async def test_update_area_with_name_already_in_use(registry): """Make sure that we can't update an area with a name already in use.""" area1 = registry.async_create("mock1") @@ -143,17 +152,31 @@ async def test_update_area_with_name_already_in_use(registry): with pytest.raises(ValueError) as e_info: registry.async_update(area1.id, name="mock2") - assert e_info == "Name is already in use" + assert e_info == "The name mock 2 (mock2) is already in use" assert area1.name == "mock1" assert area2.name == "mock2" assert len(registry.areas) == 2 +async def test_update_area_with_normalized_name_already_in_use(registry): + """Make sure that we can't update an area with a normalized name already in use.""" + area1 = registry.async_create("mock1") + area2 = registry.async_create("Moc k2") + + with pytest.raises(ValueError) as e_info: + registry.async_update(area1.id, name="mock2") + assert e_info == "The name mock 2 (mock2) is already in use" + + assert area1.name == "mock1" + assert area2.name == "Moc k2" + assert len(registry.areas) == 2 + + async def test_load_area(hass, registry): """Make sure that we can load/save data correctly.""" - registry.async_create("mock1") - registry.async_create("mock2") + area1 = registry.async_create("mock1") + area2 = registry.async_create("mock2") assert len(registry.areas) == 2 @@ -163,7 +186,13 @@ async def test_load_area(hass, registry): assert list(registry.areas) == list(registry2.areas) + area1_registry2 = registry2.async_get_or_create("mock1") + assert area1_registry2.id == area1.id + area2_registry2 = registry2.async_get_or_create("mock2") + assert area2_registry2.id == area2.id + +@pytest.mark.parametrize("load_registries", [False]) async def test_loading_area_from_storage(hass, hass_storage): """Test loading stored areas on start.""" hass_storage[area_registry.STORAGE_KEY] = { @@ -171,20 +200,45 @@ async def test_loading_area_from_storage(hass, hass_storage): "data": {"areas": [{"id": "12345A", "name": "mock"}]}, } - registry = await area_registry.async_get_registry(hass) + await area_registry.async_load(hass) + registry = area_registry.async_get(hass) assert len(registry.areas) == 1 -async def test_loading_race_condition(hass): - """Test only one storage load called when concurrent loading occurred .""" - with unittest.mock.patch( - "homeassistant.helpers.area_registry.AreaRegistry.async_load" - ) as mock_load: - results = await asyncio.gather( - area_registry.async_get_registry(hass), - area_registry.async_get_registry(hass), - ) +async def test_async_get_or_create(hass, registry): + """Make sure we can get the area by name.""" + area = registry.async_get_or_create("Mock1") + area2 = registry.async_get_or_create("mock1") + area3 = registry.async_get_or_create("mock 1") - mock_load.assert_called_once_with() - assert results[0] == results[1] + assert area == area2 + assert area == area3 + assert area2 == area3 + + +async def test_async_get_area_by_name(hass, registry): + """Make sure we can get the area by name.""" + registry.async_create("Mock1") + + assert len(registry.areas) == 1 + + assert registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" + + +async def test_async_get_area_by_name_not_found(hass, registry): + """Make sure we return None for non-existent areas.""" + registry.async_create("Mock1") + + assert len(registry.areas) == 1 + + assert registry.async_get_area_by_name("non_exist") is None + + +async def test_async_get_area(hass, registry): + """Make sure we can get the area by id.""" + area = registry.async_create("Mock1") + + assert len(registry.areas) == 1 + + assert registry.async_get_area(area.id).normalized_name == "mock1" diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index d5a8526b6da..11ab0f46ce4 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -226,7 +226,7 @@ async def test_attach_entity_component_collection(hass): """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) coll = collection.ObservableCollection(_LOGGER) - collection.attach_entity_component_collection(ent_comp, coll, MockEntity) + collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity) await coll.notify_changes( [ diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index fe2a9aa4406..5074b6e70c4 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,10 +1,10 @@ """Test the condition helper.""" -from logging import ERROR +from logging import WARNING from unittest.mock import patch import pytest -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import condition from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component @@ -34,6 +34,7 @@ async def test_and_condition(hass): test = await condition.async_from_config( hass, { + "alias": "And Condition", "condition": "and", "conditions": [ { @@ -50,6 +51,9 @@ async def test_and_condition(hass): }, ) + with pytest.raises(ConditionError): + test(hass) + hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -68,6 +72,7 @@ async def test_and_condition_with_template(hass): "condition": "and", "conditions": [ { + "alias": "Template Condition", "condition": "template", "value_template": '{{ states.sensor.temperature.state == "100" }}', }, @@ -95,6 +100,7 @@ async def test_or_condition(hass): test = await condition.async_from_config( hass, { + "alias": "Or Condition", "condition": "or", "conditions": [ { @@ -111,6 +117,9 @@ async def test_or_condition(hass): }, ) + with pytest.raises(ConditionError): + test(hass) + hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -153,6 +162,7 @@ async def test_not_condition(hass): test = await condition.async_from_config( hass, { + "alias": "Not Condition", "condition": "not", "conditions": [ { @@ -169,6 +179,9 @@ async def test_not_condition(hass): }, ) + with pytest.raises(ConditionError): + test(hass) + hass.states.async_set("sensor.temperature", 101) assert test(hass) @@ -217,36 +230,45 @@ async def test_not_condition_with_template(hass): async def test_time_window(hass): """Test time condition windows.""" - sixam = dt.parse_time("06:00:00") - sixpm = dt.parse_time("18:00:00") + 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}, + ) with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt.now().replace(hour=3), ): - assert not condition.time(hass, after=sixam, before=sixpm) - assert condition.time(hass, after=sixpm, before=sixam) + assert not test1(hass) + assert test2(hass) with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt.now().replace(hour=9), ): - assert condition.time(hass, after=sixam, before=sixpm) - assert not condition.time(hass, after=sixpm, before=sixam) + assert test1(hass) + assert not test2(hass) with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt.now().replace(hour=15), ): - assert condition.time(hass, after=sixam, before=sixpm) - assert not condition.time(hass, after=sixpm, before=sixam) + assert test1(hass) + assert not test2(hass) with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt.now().replace(hour=21), ): - assert not condition.time(hass, after=sixam, before=sixpm) - assert condition.time(hass, after=sixpm, before=sixam) + assert not test1(hass) + assert test2(hass) async def test_time_using_input_datetime(hass): @@ -334,25 +356,82 @@ async def test_time_using_input_datetime(hass): hass, after="input_datetime.pm", before="input_datetime.am" ) - assert not condition.time(hass, after="input_datetime.not_existing") - assert not condition.time(hass, before="input_datetime.not_existing") + with pytest.raises(ConditionError): + condition.time(hass, after="input_datetime.not_existing") + + with pytest.raises(ConditionError): + condition.time(hass, before="input_datetime.not_existing") -async def test_if_numeric_state_not_raise_on_unavailable(hass): - """Test numeric_state doesn't raise on unavailable/unknown state.""" +async def test_if_numeric_state_raises_on_unavailable(hass, caplog): + """Test numeric_state raises on unavailable/unknown state.""" test = await condition.async_from_config( hass, {"condition": "numeric_state", "entity_id": "sensor.temperature", "below": 42}, ) - with patch("homeassistant.helpers.condition._LOGGER.warning") as logwarn: - hass.states.async_set("sensor.temperature", "unavailable") - assert not test(hass) - assert len(logwarn.mock_calls) == 0 + caplog.clear() + caplog.set_level(WARNING) - hass.states.async_set("sensor.temperature", "unknown") - assert not test(hass) - assert len(logwarn.mock_calls) == 0 + hass.states.async_set("sensor.temperature", "unavailable") + with pytest.raises(ConditionError): + test(hass) + assert len(caplog.record_tuples) == 0 + + hass.states.async_set("sensor.temperature", "unknown") + with pytest.raises(ConditionError): + test(hass) + assert len(caplog.record_tuples) == 0 + + +async def test_state_raises(hass): + """Test that state raises ConditionError on errors.""" + # No entity + with pytest.raises(ConditionError, match="no entity"): + 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", + }, + ) + 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", + }, + ) + + hass.states.async_set("sensor.door", "open") + test(hass) async def test_state_multiple_entities(hass): @@ -392,6 +471,7 @@ async def test_multiple_states(hass): "condition": "and", "conditions": [ { + "alias": "State Condition", "condition": "state", "entity_id": "sensor.temperature", "state": ["100", "200"], @@ -428,7 +508,8 @@ async def test_state_attribute(hass): ) hass.states.async_set("sensor.temperature", 100, {"unkown_attr": 200}) - assert not test(hass) + with pytest.raises(ConditionError): + test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": 200}) assert test(hass) @@ -462,7 +543,8 @@ async def test_state_attribute_boolean(hass): assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"no_happening": 201}) - assert not test(hass) + with pytest.raises(ConditionError): + test(hass) hass.states.async_set("sensor.temperature", 100, {"happening": False}) assert test(hass) @@ -501,7 +583,6 @@ async def test_state_using_input_entities(hass): "state": [ "input_text.hello", "input_select.hello", - "input_number.not_exist", "salut", ], }, @@ -550,6 +631,125 @@ async def test_state_using_input_entities(hass): assert test(hass) +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, + }, + ) + 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, + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Unavailable state + with pytest.raises(ConditionError, match="state of .* is unavailable"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + }, + ) + + hass.states.async_set("sensor.temperature", "unavailable") + 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, + }, + ) + + 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", + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Below entity not a number + with pytest.raises( + ConditionError, + match="'below'.*input_number.missing.*cannot be processed as a number", + ): + hass.states.async_set("input_number.missing", "number") + test(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", + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Above entity not a number + with pytest.raises( + ConditionError, + match="'above'.*input_number.missing.*cannot be processed as a number", + ): + hass.states.async_set("input_number.missing", "number") + test(hass) + + async def test_numeric_state_multiple_entities(hass): """Test with multiple entities in condition.""" test = await condition.async_from_config( @@ -558,6 +758,7 @@ async def test_numeric_state_multiple_entities(hass): "condition": "and", "conditions": [ { + "alias": "Numeric State Condition", "condition": "numeric_state", "entity_id": ["sensor.temperature_1", "sensor.temperature_2"], "below": 50, @@ -579,7 +780,7 @@ async def test_numeric_state_multiple_entities(hass): assert not test(hass) -async def test_numberic_state_attribute(hass): +async def test_numeric_state_attribute(hass): """Test with numeric state attribute in condition.""" test = await condition.async_from_config( hass, @@ -597,7 +798,8 @@ async def test_numberic_state_attribute(hass): ) hass.states.async_set("sensor.temperature", 100, {"unkown_attr": 10}) - assert not test(hass) + with pytest.raises(ConditionError): + assert test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": 49}) assert test(hass) @@ -609,7 +811,8 @@ async def test_numberic_state_attribute(hass): assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": None}) - assert not test(hass) + with pytest.raises(ConditionError): + assert test(hass) async def test_numeric_state_using_input_number(hass): @@ -660,13 +863,101 @@ async def test_numeric_state_using_input_number(hass): ) assert test(hass) - assert not condition.async_numeric_state( - hass, entity="sensor.temperature", below="input_number.not_exist" + with pytest.raises(ConditionError): + condition.async_numeric_state( + hass, entity="sensor.temperature", below="input_number.not_exist" + ) + with pytest.raises(ConditionError): + condition.async_numeric_state( + hass, entity="sensor.temperature", above="input_number.not_exist" + ) + + +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", + }, ) - assert not condition.async_numeric_state( - hass, entity="sensor.temperature", above="input_number.not_exist" + + with pytest.raises(ConditionError, match="no zone"): + condition.zone(hass, zone_ent=None, entity="sensor.any") + + with pytest.raises(ConditionError, match="unknown zone"): + test(hass) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, ) + with pytest.raises(ConditionError, match="no entity"): + condition.zone(hass, zone_ent="zone.home", entity=None) + + with pytest.raises(ConditionError, match="unknown entity"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat"}, + ) + + with pytest.raises(ConditionError, match="latitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1}, + ) + + with pytest.raises(ConditionError, match="longitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1}, + ) + + # 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"], + }, + ) + + with pytest.raises(ConditionError, match="dog"): + test(hass) + + with pytest.raises(ConditionError, match="work"): + test(hass) + + hass.states.async_set( + "zone.work", + "zoning", + {"name": "work", "latitude": 20, "longitude": 10, "radius": 25000}, + ) + + hass.states.async_set( + "device_tracker.dog", + "work", + {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, + ) + + assert test(hass) + async def test_zone_multiple_entities(hass): """Test with multiple entities in condition.""" @@ -676,6 +967,7 @@ async def test_zone_multiple_entities(hass): "condition": "and", "conditions": [ { + "alias": "Zone Condition", "condition": "zone", "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], "zone": "zone.home", @@ -901,19 +1193,14 @@ async def test_extract_devices(): ) -async def test_condition_template_error(hass, caplog): +async def test_condition_template_error(hass): """Test invalid template.""" - caplog.set_level(ERROR) - test = await condition.async_from_config( hass, {"condition": "template", "value_template": "{{ undefined.state }}"} ) - assert not test(hass) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith( - "Error during template condition: UndefinedError:" - ) + with pytest.raises(ConditionError, match="template"): + test(hass) async def test_condition_template_invalid_results(hass): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 1397e499c7e..d0ae86f8f7e 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -358,6 +358,11 @@ def test_service_schema(): "service": "homeassistant.turn_on", "entity_id": ["light.kitchen", "light.ceiling"], }, + { + "service": "light.turn_on", + "entity_id": "all", + "alias": "turn on kitchen lights", + }, ) for value in options: cv.SERVICE_SCHEMA(value) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 01959174335..965ebcd3e23 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,5 +1,4 @@ """Tests for the Device Registry.""" -import asyncio import time from unittest.mock import patch @@ -9,7 +8,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, callback from homeassistant.helpers import device_registry, entity_registry -from tests.common import MockConfigEntry, flush_store, mock_device_registry +from tests.common import ( + MockConfigEntry, + flush_store, + mock_area_registry, + mock_device_registry, +) @pytest.fixture @@ -18,6 +22,12 @@ def registry(hass): return mock_device_registry(hass) +@pytest.fixture +def area_registry(hass): + """Return an empty, loaded, registry.""" + return mock_area_registry(hass) + + @pytest.fixture def update_events(hass): """Capture update events.""" @@ -32,7 +42,9 @@ def update_events(hass): return events -async def test_get_or_create_returns_same_entry(hass, registry, update_events): +async def test_get_or_create_returns_same_entry( + hass, registry, area_registry, update_events +): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( config_entry_id="1234", @@ -42,6 +54,7 @@ async def test_get_or_create_returns_same_entry(hass, registry, update_events): name="name", manufacturer="manufacturer", model="model", + suggested_area="Game Room", ) entry2 = registry.async_get_or_create( config_entry_id="1234", @@ -49,21 +62,31 @@ async def test_get_or_create_returns_same_entry(hass, registry, update_events): identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", + suggested_area="Game Room", ) entry3 = registry.async_get_or_create( config_entry_id="1234", connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) + game_room_area = area_registry.async_get_area_by_name("Game Room") + assert game_room_area is not None + assert len(area_registry.areas) == 1 + assert len(registry.devices) == 1 + assert entry.area_id == game_room_area.id assert entry.id == entry2.id assert entry.id == entry3.id assert entry.identifiers == {("bridgeid", "0123")} + assert entry2.area_id == game_room_area.id + assert entry3.manufacturer == "manufacturer" assert entry3.model == "model" assert entry3.name == "name" assert entry3.sw_version == "sw-version" + assert entry3.suggested_area == "Game Room" + assert entry3.area_id == game_room_area.id await hass.async_block_till_done() @@ -135,6 +158,7 @@ async def test_multiple_config_entries(registry): assert entry2.config_entries == {"123", "456"} +@pytest.mark.parametrize("load_registries", [False]) async def test_loading_from_storage(hass, hass_storage): """Test loading stored devices on start.""" hass_storage[device_registry.STORAGE_KEY] = { @@ -154,6 +178,7 @@ async def test_loading_from_storage(hass, hass_storage): "area_id": "12345A", "name_by_user": "Test Friendly Name", "disabled_by": "user", + "suggested_area": "Kitchen", } ], "deleted_devices": [ @@ -167,7 +192,8 @@ async def test_loading_from_storage(hass, hass_storage): }, } - registry = await device_registry.async_get_registry(hass) + await device_registry.async_load(hass) + registry = device_registry.async_get(hass) assert len(registry.devices) == 1 assert len(registry.deleted_devices) == 1 @@ -443,7 +469,7 @@ async def test_specifying_via_device_update(registry): assert light.via_device_id == via.id -async def test_loading_saving_data(hass, registry): +async def test_loading_saving_data(hass, registry, area_registry): """Test that we load/save data correctly.""" orig_via = registry.async_get_or_create( config_entry_id="123", @@ -505,7 +531,18 @@ async def test_loading_saving_data(hass, registry): assert orig_light4.id == orig_light3.id - assert len(registry.devices) == 3 + orig_kitchen_light = registry.async_get_or_create( + config_entry_id="999", + connections=set(), + identifiers={("hue", "999")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), + disabled_by="user", + suggested_area="Kitchen", + ) + + assert len(registry.devices) == 4 assert len(registry.deleted_devices) == 1 orig_via = registry.async_update_device( @@ -529,6 +566,16 @@ async def test_loading_saving_data(hass, registry): assert orig_light == new_light assert orig_light4 == new_light4 + # Ensure a save/load cycle does not keep suggested area + new_kitchen_light = registry2.async_get_device({("hue", "999")}) + assert orig_kitchen_light.suggested_area == "Kitchen" + + orig_kitchen_light_witout_suggested_area = registry.async_update_device( + orig_kitchen_light.id, suggested_area=None + ) + orig_kitchen_light_witout_suggested_area.suggested_area is None + assert orig_kitchen_light_witout_suggested_area == new_kitchen_light + async def test_no_unnecessary_changes(registry): """Make sure we do not consider devices changes.""" @@ -687,20 +734,6 @@ async def test_update_remove_config_entries(hass, registry, update_events): assert update_events[4]["device_id"] == entry3.id -async def test_loading_race_condition(hass): - """Test only one storage load called when concurrent loading occurred .""" - with patch( - "homeassistant.helpers.device_registry.DeviceRegistry.async_load" - ) as mock_load: - results = await asyncio.gather( - device_registry.async_get_registry(hass), - device_registry.async_get_registry(hass), - ) - - mock_load.assert_called_once_with() - assert results[0] == results[1] - - async def test_update_sw_version(registry): """Verify that we can update software version of a device.""" entry = registry.async_get_or_create( @@ -719,6 +752,33 @@ async def test_update_sw_version(registry): assert updated_entry.sw_version == sw_version +async def test_update_suggested_area(registry, area_registry): + """Verify that we can update the suggested area version of a device.""" + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bla", "123")}, + ) + assert not entry.suggested_area + assert entry.area_id is None + + suggested_area = "Pool" + + with patch.object(registry, "async_schedule_save") as mock_save: + updated_entry = registry.async_update_device( + entry.id, suggested_area=suggested_area + ) + + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.suggested_area == suggested_area + + pool_area = area_registry.async_get_area_by_name("Pool") + assert pool_area is not None + assert updated_entry.area_id == pool_area.id + assert len(area_registry.areas) == 1 + + async def test_cleanup_device_registry(hass, registry): """Test cleanup works.""" config_entry = MockConfigEntry(domain="hue") @@ -798,10 +858,16 @@ async def test_cleanup_startup(hass): assert len(mock_call.mock_calls) == 1 +@pytest.mark.parametrize("load_registries", [False]) async def test_cleanup_entity_registry_change(hass): - """Test we run a cleanup when entity registry changes.""" - await device_registry.async_get_registry(hass) - ent_reg = await entity_registry.async_get_registry(hass) + """Test we run a cleanup when entity registry changes. + + Don't pre-load the registries as the debouncer will then not be waiting for + EVENT_ENTITY_REGISTRY_UPDATED events. + """ + await device_registry.async_load(hass) + await entity_registry.async_load(hass) + ent_reg = entity_registry.async_get(hass) with patch( "homeassistant.helpers.device_registry.Debouncer.async_call" @@ -1111,3 +1177,73 @@ async def test_get_or_create_sets_default_values(hass, registry): assert entry.name == "default name 1" assert entry.model == "default model 1" assert entry.manufacturer == "default manufacturer 1" + + +async def test_verify_suggested_area_does_not_overwrite_area_id( + hass, registry, area_registry +): + """Make sure suggested area does not override a set area id.""" + game_room_area = area_registry.async_create("Game Room") + + original_entry = registry.async_get_or_create( + config_entry_id="1234", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + ) + entry = registry.async_update_device(original_entry.id, area_id=game_room_area.id) + + assert entry.area_id == game_room_area.id + + entry2 = registry.async_get_or_create( + config_entry_id="1234", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + suggested_area="New Game Room", + ) + assert entry2.area_id == game_room_area.id + + +async def test_disable_config_entry_disables_devices(hass, registry): + """Test that we disable entities tied to a config entry.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + entry1 = registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + entry2 = registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", "34:56:AB:CD:EF:12")}, + disabled_by="user", + ) + + assert not entry1.disabled + assert entry2.disabled + + await hass.config_entries.async_set_disabled_by(config_entry.entry_id, "user") + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert entry1.disabled + assert entry1.disabled_by == "config_entry" + entry2 = registry.async_get(entry2.id) + assert entry2.disabled + assert entry2.disabled_by == "user" + + await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert not entry1.disabled + entry2 = registry.async_get(entry2.id) + assert entry2.disabled + assert entry2.disabled_by == "user" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 52149b060e4..b8d0fc7dc9c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Context from homeassistant.helpers import entity, entity_registry @@ -718,3 +718,29 @@ async def test_setup_source(hass): await platform.async_reset() assert entity.entity_sources(hass) == {} + + +async def test_removing_entity_unavailable(hass): + """Test removing an entity that is still registered creates an unavailable state.""" + entry = entity_registry.RegistryEntry( + entity_id="hello.world", + unique_id="test-unique-id", + platform="test-platform", + disabled_by=None, + ) + + ent = entity.Entity() + ent.hass = hass + ent.entity_id = "hello.world" + ent.registry_entry = entry + ent.async_write_ha_state() + + state = hass.states.get("hello.world") + assert state is not None + assert state.state == STATE_UNKNOWN + + await ent.async_remove() + + state = hass.states.get("hello.world") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 0a939ba2825..ab3e04843f9 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -728,6 +728,7 @@ async def test_device_info_called(hass): "model": "test-model", "name": "test-name", "sw_version": "test-sw", + "suggested_area": "Heliport", "entry_type": "service", "via_device": ("hue", "via-id"), }, @@ -755,6 +756,7 @@ async def test_device_info_called(hass): assert device.model == "test-model" assert device.name == "test-name" assert device.sw_version == "test-sw" + assert device.suggested_area == "Heliport" assert device.entry_type == "service" assert device.via_device_id == via.id diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 21f4392122e..0a1a27efef5 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,6 +1,4 @@ """Tests for the Entity Registry.""" -import asyncio -import unittest.mock from unittest.mock import patch import pytest @@ -219,6 +217,7 @@ def test_is_registered(registry): assert not registry.async_is_registered("light.non_existing") +@pytest.mark.parametrize("load_registries", [False]) async def test_loading_extra_values(hass, hass_storage): """Test we load extra data from the registry.""" hass_storage[entity_registry.STORAGE_KEY] = { @@ -258,7 +257,8 @@ async def test_loading_extra_values(hass, hass_storage): }, } - registry = await entity_registry.async_get_registry(hass) + await entity_registry.async_load(hass) + registry = entity_registry.async_get(hass) assert len(registry.entities) == 4 @@ -313,7 +313,7 @@ async def test_updating_config_entry_id(hass, registry, update_events): assert update_events[0]["entity_id"] == entry.entity_id assert update_events[1]["action"] == "update" assert update_events[1]["entity_id"] == entry.entity_id - assert update_events[1]["changes"] == ["config_entry_id"] + assert update_events[1]["changes"] == {"config_entry_id": "mock-id-1"} async def test_removing_config_entry_id(hass, registry, update_events): @@ -350,6 +350,7 @@ async def test_removing_area_id(registry): assert entry_w_area != entry_wo_area +@pytest.mark.parametrize("load_registries", [False]) async def test_migration(hass): """Test migration from old data to new.""" mock_config = MockConfigEntry(domain="test-platform", entry_id="test-config-id") @@ -366,7 +367,8 @@ async def test_migration(hass): with patch("os.path.isfile", return_value=True), patch("os.remove"), patch( "homeassistant.helpers.entity_registry.load_yaml", return_value=old_conf ): - registry = await entity_registry.async_get_registry(hass) + await entity_registry.async_load(hass) + registry = entity_registry.async_get(hass) assert registry.async_is_registered("light.kitchen") entry = registry.async_get_or_create( @@ -427,20 +429,6 @@ async def test_loading_invalid_entity_id(hass, hass_storage): assert valid_entity_id(entity_invalid_start.entity_id) -async def test_loading_race_condition(hass): - """Test only one storage load called when concurrent loading occurred .""" - with unittest.mock.patch( - "homeassistant.helpers.entity_registry.EntityRegistry.async_load" - ) as mock_load: - results = await asyncio.gather( - entity_registry.async_get_registry(hass), - entity_registry.async_get_registry(hass), - ) - - mock_load.assert_called_once_with() - assert results[0] == results[1] - - async def test_update_entity_unique_id(registry): """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") @@ -710,6 +698,39 @@ async def test_remove_device_removes_entities(hass, registry): assert not registry.async_is_registered(entry.entity_id) +async def test_update_device_race(hass, registry): + """Test race when a device is created, updated and removed.""" + device_registry = mock_device_registry(hass) + config_entry = MockConfigEntry(domain="light") + + # Create device + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + # Update it + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + # Add entity to the device + entry = registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + assert registry.async_is_registered(entry.entity_id) + + device_registry.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + assert not registry.async_is_registered(entry.entity_id) + + async def test_disable_device_disables_entities(hass, registry): """Test that we disable entities tied to a device.""" device_registry = mock_device_registry(hass) @@ -736,9 +757,18 @@ async def test_disable_device_disables_entities(hass, registry): device_id=device_entry.id, disabled_by="user", ) + entry3 = registry.async_get_or_create( + "light", + "hue", + "EFGH", + config_entry=config_entry, + device_id=device_entry.id, + disabled_by="config_entry", + ) assert not entry1.disabled assert entry2.disabled + assert entry3.disabled device_registry.async_update_device(device_entry.id, disabled_by="user") await hass.async_block_till_done() @@ -749,6 +779,9 @@ async def test_disable_device_disables_entities(hass, registry): entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled assert entry2.disabled_by == "user" + entry3 = registry.async_get(entry3.entity_id) + assert entry3.disabled + assert entry3.disabled_by == "config_entry" device_registry.async_update_device(device_entry.id, disabled_by=None) await hass.async_block_till_done() @@ -758,10 +791,78 @@ async def test_disable_device_disables_entities(hass, registry): entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled assert entry2.disabled_by == "user" + entry3 = registry.async_get(entry3.entity_id) + assert entry3.disabled + assert entry3.disabled_by == "config_entry" + + +async def test_disable_config_entry_disables_entities(hass, registry): + """Test that we disable entities tied to a config entry.""" + device_registry = mock_device_registry(hass) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + + entry1 = registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + entry2 = registry.async_get_or_create( + "light", + "hue", + "ABCD", + config_entry=config_entry, + device_id=device_entry.id, + disabled_by="user", + ) + entry3 = registry.async_get_or_create( + "light", + "hue", + "EFGH", + config_entry=config_entry, + device_id=device_entry.id, + disabled_by="device", + ) + + assert not entry1.disabled + assert entry2.disabled + assert entry3.disabled + + await hass.config_entries.async_set_disabled_by(config_entry.entry_id, "user") + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.entity_id) + assert entry1.disabled + assert entry1.disabled_by == "config_entry" + entry2 = registry.async_get(entry2.entity_id) + assert entry2.disabled + assert entry2.disabled_by == "user" + entry3 = registry.async_get(entry3.entity_id) + assert entry3.disabled + assert entry3.disabled_by == "device" + + await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.entity_id) + assert not entry1.disabled + entry2 = registry.async_get(entry2.entity_id) + assert entry2.disabled + assert entry2.disabled_by == "user" + # The device was re-enabled, so entity disabled by the device will be re-enabled too + entry3 = registry.async_get(entry3.entity_id) + assert not entry3.disabled_by async def test_disabled_entities_excluded_from_entity_list(hass, registry): - """Test that disabled entities are exclduded from async_entries_for_device.""" + """Test that disabled entities are excluded from async_entries_for_device.""" device_registry = mock_device_registry(hass) config_entry = MockConfigEntry(domain="light") diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 06158558d5e..aad37e2fd49 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.network import ( NoURLAvailableError, _get_cloud_url, - _get_deprecated_base_url, _get_external_url, _get_internal_url, _get_request_host, @@ -166,9 +165,7 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): """Test getting an instance URL when the user has not set an internal URL.""" assert hass.config.internal_url is None - hass.config.api = Mock( - use_ssl=False, port=8123, deprecated_base_url=None, local_ip="192.168.123.123" - ) + hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") assert _get_internal_url(hass) == "http://192.168.123.123:8123" with pytest.raises(NoURLAvailableError): @@ -180,9 +177,7 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): with pytest.raises(NoURLAvailableError): _get_internal_url(hass, require_ssl=True) - hass.config.api = Mock( - use_ssl=False, port=80, deprecated_base_url=None, local_ip="192.168.123.123" - ) + hass.config.api = Mock(use_ssl=False, port=80, local_ip="192.168.123.123") assert _get_internal_url(hass) == "http://192.168.123.123" assert ( _get_internal_url(hass, require_standard_port=True) == "http://192.168.123.123" @@ -194,7 +189,7 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): with pytest.raises(NoURLAvailableError): _get_internal_url(hass, require_ssl=True) - hass.config.api = Mock(use_ssl=True, port=443, deprecated_base_url=None) + hass.config.api = Mock(use_ssl=True, port=443) with pytest.raises(NoURLAvailableError): _get_internal_url(hass) @@ -208,9 +203,7 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): _get_internal_url(hass, require_ssl=True) # Do no accept any local loopback address as fallback - hass.config.api = Mock( - use_ssl=False, port=80, deprecated_base_url=None, local_ip="127.0.0.1" - ) + hass.config.api = Mock(use_ssl=False, port=80, local_ip="127.0.0.1") with pytest.raises(NoURLAvailableError): _get_internal_url(hass) @@ -457,9 +450,7 @@ async def test_get_url(hass: HomeAssistant): with pytest.raises(NoURLAvailableError): get_url(hass) - hass.config.api = Mock( - use_ssl=False, port=8123, deprecated_base_url=None, local_ip="192.168.123.123" - ) + hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") assert get_url(hass) == "http://192.168.123.123:8123" assert get_url(hass, prefer_external=True) == "http://192.168.123.123:8123" @@ -543,274 +534,11 @@ async def test_get_request_host(hass: HomeAssistant): assert _get_request_host() == "example.com" -async def test_get_deprecated_base_url_internal(hass: HomeAssistant): - """Test getting an internal instance URL from the deprecated base_url.""" - # Test with SSL local URL - hass.config.api = Mock(deprecated_base_url="https://example.local") - assert _get_deprecated_base_url(hass, internal=True) == "https://example.local" - assert ( - _get_deprecated_base_url(hass, internal=True, allow_ip=False) - == "https://example.local" - ) - assert ( - _get_deprecated_base_url(hass, internal=True, require_ssl=True) - == "https://example.local" - ) - assert ( - _get_deprecated_base_url(hass, internal=True, require_standard_port=True) - == "https://example.local" - ) - - # Test with no SSL, local IP URL - hass.config.api = Mock(deprecated_base_url="http://10.10.10.10:8123") - assert _get_deprecated_base_url(hass, internal=True) == "http://10.10.10.10:8123" - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_standard_port=True) - - # Test with SSL, local IP URL - hass.config.api = Mock(deprecated_base_url="https://10.10.10.10") - assert _get_deprecated_base_url(hass, internal=True) == "https://10.10.10.10" - assert ( - _get_deprecated_base_url(hass, internal=True, require_ssl=True) - == "https://10.10.10.10" - ) - assert ( - _get_deprecated_base_url(hass, internal=True, require_standard_port=True) - == "https://10.10.10.10" - ) - - # Test external URL - hass.config.api = Mock(deprecated_base_url="https://example.com") - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_standard_port=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, allow_ip=False) - - # Test with loopback - hass.config.api = Mock(deprecated_base_url="https://127.0.0.42") - with pytest.raises(NoURLAvailableError): - assert _get_deprecated_base_url(hass, internal=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, internal=True, require_standard_port=True) - - -async def test_get_deprecated_base_url_external(hass: HomeAssistant): - """Test getting an external instance URL from the deprecated base_url.""" - # Test with SSL and external domain on standard port - hass.config.api = Mock(deprecated_base_url="https://example.com:443/") - assert _get_deprecated_base_url(hass) == "https://example.com" - assert _get_deprecated_base_url(hass, require_ssl=True) == "https://example.com" - assert ( - _get_deprecated_base_url(hass, require_standard_port=True) - == "https://example.com" - ) - - # Test without SSL and external domain on non-standard port - hass.config.api = Mock(deprecated_base_url="http://example.com:8123/") - assert _get_deprecated_base_url(hass) == "http://example.com:8123" - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_standard_port=True) - - # Test SSL on external IP - hass.config.api = Mock(deprecated_base_url="https://1.1.1.1") - assert _get_deprecated_base_url(hass) == "https://1.1.1.1" - assert _get_deprecated_base_url(hass, require_ssl=True) == "https://1.1.1.1" - assert ( - _get_deprecated_base_url(hass, require_standard_port=True) == "https://1.1.1.1" - ) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, allow_ip=False) - - # Test with private IP - hass.config.api = Mock(deprecated_base_url="https://10.10.10.10") - with pytest.raises(NoURLAvailableError): - assert _get_deprecated_base_url(hass) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_standard_port=True) - - # Test with local domain - hass.config.api = Mock(deprecated_base_url="https://example.local") - with pytest.raises(NoURLAvailableError): - assert _get_deprecated_base_url(hass) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_standard_port=True) - - # Test with loopback - hass.config.api = Mock(deprecated_base_url="https://127.0.0.42") - with pytest.raises(NoURLAvailableError): - assert _get_deprecated_base_url(hass) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_ssl=True) - - with pytest.raises(NoURLAvailableError): - _get_deprecated_base_url(hass, require_standard_port=True) - - -async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant): - """Test getting an internal instance URL with the deprecated base_url fallback.""" - hass.config.api = Mock( - use_ssl=False, port=8123, deprecated_base_url=None, local_ip="192.168.123.123" - ) - assert hass.config.internal_url is None - assert _get_internal_url(hass) == "http://192.168.123.123:8123" - - with pytest.raises(NoURLAvailableError): - _get_internal_url(hass, allow_ip=False) - - with pytest.raises(NoURLAvailableError): - _get_internal_url(hass, require_standard_port=True) - - with pytest.raises(NoURLAvailableError): - _get_internal_url(hass, require_ssl=True) - - # Add base_url - hass.config.api = Mock( - use_ssl=False, port=8123, deprecated_base_url="https://example.local" - ) - assert _get_internal_url(hass) == "https://example.local" - assert _get_internal_url(hass, allow_ip=False) == "https://example.local" - assert ( - _get_internal_url(hass, require_standard_port=True) == "https://example.local" - ) - assert _get_internal_url(hass, require_ssl=True) == "https://example.local" - - # Add internal URL - await async_process_ha_core_config( - hass, - {"internal_url": "https://internal.local"}, - ) - assert _get_internal_url(hass) == "https://internal.local" - assert _get_internal_url(hass, allow_ip=False) == "https://internal.local" - assert ( - _get_internal_url(hass, require_standard_port=True) == "https://internal.local" - ) - assert _get_internal_url(hass, require_ssl=True) == "https://internal.local" - - # Add internal URL, mixed results - await async_process_ha_core_config( - hass, - {"internal_url": "http://internal.local:8123"}, - ) - assert _get_internal_url(hass) == "http://internal.local:8123" - assert _get_internal_url(hass, allow_ip=False) == "http://internal.local:8123" - assert ( - _get_internal_url(hass, require_standard_port=True) == "https://example.local" - ) - assert _get_internal_url(hass, require_ssl=True) == "https://example.local" - - # Add internal URL set to an IP - await async_process_ha_core_config( - hass, - {"internal_url": "http://10.10.10.10:8123"}, - ) - assert _get_internal_url(hass) == "http://10.10.10.10:8123" - assert _get_internal_url(hass, allow_ip=False) == "https://example.local" - assert ( - _get_internal_url(hass, require_standard_port=True) == "https://example.local" - ) - assert _get_internal_url(hass, require_ssl=True) == "https://example.local" - - -async def test_get_external_url_with_base_url_fallback(hass: HomeAssistant): - """Test getting an external instance URL with the deprecated base_url fallback.""" - hass.config.api = Mock(use_ssl=False, port=8123, deprecated_base_url=None) - assert hass.config.internal_url is None - - with pytest.raises(NoURLAvailableError): - _get_external_url(hass) - - # Test with SSL and external domain on standard port - hass.config.api = Mock(deprecated_base_url="https://example.com:443/") - assert _get_external_url(hass) == "https://example.com" - assert _get_external_url(hass, allow_ip=False) == "https://example.com" - assert _get_external_url(hass, require_ssl=True) == "https://example.com" - assert _get_external_url(hass, require_standard_port=True) == "https://example.com" - - # Add external URL - await async_process_ha_core_config( - hass, - {"external_url": "https://external.example.com"}, - ) - assert _get_external_url(hass) == "https://external.example.com" - assert _get_external_url(hass, allow_ip=False) == "https://external.example.com" - assert ( - _get_external_url(hass, require_standard_port=True) - == "https://external.example.com" - ) - assert _get_external_url(hass, require_ssl=True) == "https://external.example.com" - - # Add external URL, mixed results - await async_process_ha_core_config( - hass, - {"external_url": "http://external.example.com:8123"}, - ) - assert _get_external_url(hass) == "http://external.example.com:8123" - assert _get_external_url(hass, allow_ip=False) == "http://external.example.com:8123" - assert _get_external_url(hass, require_standard_port=True) == "https://example.com" - assert _get_external_url(hass, require_ssl=True) == "https://example.com" - - # Add external URL set to an IP - await async_process_ha_core_config( - hass, - {"external_url": "http://1.1.1.1:8123"}, - ) - assert _get_external_url(hass) == "http://1.1.1.1:8123" - assert _get_external_url(hass, allow_ip=False) == "https://example.com" - assert _get_external_url(hass, require_standard_port=True) == "https://example.com" - assert _get_external_url(hass, require_ssl=True) == "https://example.com" - - async def test_get_current_request_url_with_known_host( hass: HomeAssistant, current_request ): """Test getting current request URL with known hosts addresses.""" - hass.config.api = Mock( - use_ssl=False, port=8123, local_ip="127.0.0.1", deprecated_base_url=None - ) + hass.config.api = Mock(use_ssl=False, port=8123, local_ip="127.0.0.1") assert hass.config.internal_url is None with pytest.raises(NoURLAvailableError): diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 65d8b442bf0..d2946fcd494 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -50,7 +50,10 @@ async def test_firing_event_basic(hass, caplog): context = Context() events = async_capture_events(hass, event) - sequence = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}}) + alias = "event step" + sequence = cv.SCRIPT_SCHEMA( + {"alias": alias, "event": event, "event_data": {"hello": "world"}} + ) script_obj = script.Script( hass, sequence, "Test Name", "test_domain", running_description="test script" ) @@ -63,6 +66,7 @@ async def test_firing_event_basic(hass, caplog): assert events[0].data.get("hello") == "world" assert ".test_name:" in caplog.text assert "Test Name: Running test script" in caplog.text + assert f"Executing step {alias}" in caplog.text async def test_firing_event_template(hass): @@ -107,12 +111,15 @@ async def test_firing_event_template(hass): } -async def test_calling_service_basic(hass): +async def test_calling_service_basic(hass, caplog): """Test the calling of a service.""" context = Context() calls = async_mock_service(hass, "test", "script") - sequence = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}}) + alias = "service step" + sequence = cv.SCRIPT_SCHEMA( + {"alias": alias, "service": "test.script", "data": {"hello": "world"}} + ) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") await script_obj.async_run(context=context) @@ -121,6 +128,7 @@ async def test_calling_service_basic(hass): assert len(calls) == 1 assert calls[0].context is context assert calls[0].data.get("hello") == "world" + assert f"Executing step {alias}" in caplog.text async def test_calling_service_template(hass): @@ -250,12 +258,13 @@ async def test_multiple_runs_no_wait(hass): assert len(calls) == 4 -async def test_activating_scene(hass): +async def test_activating_scene(hass, caplog): """Test the activation of a scene.""" context = Context() calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - sequence = cv.SCRIPT_SCHEMA({"scene": "scene.hello"}) + alias = "scene step" + sequence = cv.SCRIPT_SCHEMA({"alias": alias, "scene": "scene.hello"}) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") await script_obj.async_run(context=context) @@ -264,6 +273,7 @@ async def test_activating_scene(hass): assert len(calls) == 1 assert calls[0].context is context assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello" + assert f"Executing step {alias}" in caplog.text @pytest.mark.parametrize("count", [1, 3]) @@ -545,6 +555,49 @@ async def test_wait_basic(hass, action_type): assert script_obj.last_action is None +async def test_wait_for_trigger_variables(hass): + """Test variables are passed to wait_for_trigger action.""" + context = Context() + wait_alias = "wait step" + actions = [ + { + "alias": "variables", + "variables": {"seconds": 5}, + }, + { + "alias": wait_alias, + "wait_for_trigger": { + "platform": "state", + "entity_id": "switch.test", + "to": "off", + "for": {"seconds": "{{ seconds }}"}, + }, + }, + ] + sequence = cv.SCRIPT_SCHEMA(actions) + 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) + + try: + hass.states.async_set("switch.test", "on") + hass.async_create_task(script_obj.async_run(context=context)) + await asyncio.wait_for(wait_started_flag.wait(), 1) + assert script_obj.is_running + assert script_obj.last_action == wait_alias + hass.states.async_set("switch.test", "off") + # the script task + 2 tasks created by wait_for_trigger script step + await hass.async_wait_for_task_count(3) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + assert not script_obj.is_running + assert script_obj.last_action is None + + @pytest.mark.parametrize("action_type", ["template", "trigger"]) async def test_wait_basic_times_out(hass, action_type): """Test wait actions times out when the action does not happen.""" @@ -990,14 +1043,46 @@ async def test_wait_for_trigger_generated_exception(hass, caplog): assert "something bad" in caplog.text -async def test_condition_basic(hass): - """Test if we can use conditions in a script.""" +async def test_condition_warning(hass, caplog): + """Test warning on condition.""" event = "test_event" events = async_capture_events(hass, event) sequence = cv.SCRIPT_SCHEMA( [ {"event": event}, { + "condition": "numeric_state", + "entity_id": "test.entity", + "above": 0, + }, + {"event": event}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + caplog.clear() + caplog.set_level(logging.WARNING) + + hass.states.async_set("test.entity", "string") + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.WARNING + + assert len(events) == 1 + + +async def test_condition_basic(hass, caplog): + """Test if we can use conditions in a script.""" + event = "test_event" + events = async_capture_events(hass, event) + alias = "condition step" + sequence = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "alias": alias, "condition": "template", "value_template": "{{ states.test.entity.state == 'hello' }}", }, @@ -1010,6 +1095,8 @@ async def test_condition_basic(hass): 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 hass.states.async_set("test.entity", "goodbye") @@ -1017,6 +1104,7 @@ async def test_condition_basic(hass): 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 @@ -1067,14 +1155,16 @@ async def test_condition_all_cached(hass): assert len(script_obj._config_cache) == 2 -async def test_repeat_count(hass): +async def test_repeat_count(hass, caplog): """Test repeat action w/ count option.""" event = "test_event" events = async_capture_events(hass, event) count = 3 + alias = "condition step" sequence = cv.SCRIPT_SCHEMA( { + "alias": alias, "repeat": { "count": count, "sequence": { @@ -1085,7 +1175,7 @@ async def test_repeat_count(hass): "last": "{{ repeat.last }}", }, }, - } + }, } ) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") @@ -1098,6 +1188,49 @@ async def test_repeat_count(hass): assert event.data.get("first") == (index == 0) assert event.data.get("index") == index + 1 assert event.data.get("last") == (index == count - 1) + assert caplog.text.count(f"Repeating {alias}") == count + + +@pytest.mark.parametrize("condition", ["while", "until"]) +async def test_repeat_condition_warning(hass, caplog, condition): + """Test warning on repeat conditions.""" + event = "test_event" + events = async_capture_events(hass, event) + count = 0 if condition == "while" else 1 + + sequence = { + "repeat": { + "sequence": [ + { + "event": event, + }, + ], + } + } + sequence["repeat"][condition] = { + "condition": "numeric_state", + "entity_id": "sensor.test", + "value_template": "{{ unassigned_variable }}", + "above": "0", + } + + script_obj = script.Script( + hass, cv.SCRIPT_SCHEMA(sequence), f"Test {condition}", "test_domain" + ) + + # wait_started = async_watch_for_action(script_obj, "wait") + hass.states.async_set("sensor.test", "1") + + caplog.clear() + caplog.set_level(logging.WARNING) + + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.wait_for(hass.async_block_till_done(), 1) + + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.WARNING + + assert len(events) == count @pytest.mark.parametrize("condition", ["while", "until"]) @@ -1305,23 +1438,30 @@ async def test_repeat_nested(hass, variables, first_last, inside_x): } -@pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")]) -async def test_choose(hass, var, result): - """Test choose action.""" +async def test_choose_warning(hass, caplog): + """Test warning on choose.""" event = "test_event" events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( { "choose": [ { "conditions": { - "condition": "template", - "value_template": "{{ var == 1 }}", + "condition": "numeric_state", + "entity_id": "test.entity", + "value_template": "{{ undefined_a + undefined_b }}", + "above": 1, }, "sequence": {"event": event, "event_data": {"choice": "first"}}, }, { - "conditions": "{{ var == 2 }}", + "conditions": { + "condition": "numeric_state", + "entity_id": "test.entity", + "value_template": "{{ 'string' }}", + "above": 2, + }, "sequence": {"event": event, "event_data": {"choice": "second"}}, }, ], @@ -1330,11 +1470,75 @@ async def test_choose(hass, var, result): ) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + hass.states.async_set("test.entity", "9") + await hass.async_block_till_done() + + caplog.clear() + caplog.set_level(logging.WARNING) + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(caplog.record_tuples) == 2 + assert caplog.record_tuples[0][1] == logging.WARNING + assert caplog.record_tuples[1][1] == logging.WARNING + + assert len(events) == 1 + assert events[0].data["choice"] == "default" + + +@pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")]) +async def test_choose(hass, caplog, var, result): + """Test choose action.""" + event = "test_event" + events = async_capture_events(hass, event) + alias = "choose step" + choice = {1: "choice one", 2: "choice two", 3: None} + aliases = {1: "sequence one", 2: "sequence two", 3: "default sequence"} + sequence = cv.SCRIPT_SCHEMA( + { + "alias": alias, + "choose": [ + { + "alias": choice[1], + "conditions": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "sequence": { + "alias": aliases[1], + "event": event, + "event_data": {"choice": "first"}, + }, + }, + { + "alias": choice[2], + "conditions": "{{ var == 2 }}", + "sequence": { + "alias": aliases[2], + "event": event, + "event_data": {"choice": "second"}, + }, + }, + ], + "default": { + "alias": aliases[3], + "event": event, + "event_data": {"choice": "default"}, + }, + } + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + await script_obj.async_run(MappingProxyType({"var": var}), Context()) await hass.async_block_till_done() assert len(events) == 1 assert events[0].data["choice"] == result + expected_choice = choice[var] + if var == 3: + expected_choice = "default" + assert f"{alias}: {expected_choice}: Executing step {aliases[var]}" in caplog.text @pytest.mark.parametrize( @@ -1951,9 +2155,10 @@ async def test_started_action(hass, caplog): async def test_set_variable(hass, caplog): """Test setting variables in scripts.""" + alias = "variables step" sequence = cv.SCRIPT_SCHEMA( [ - {"variables": {"variable": "value"}}, + {"alias": alias, "variables": {"variable": "value"}}, {"service": "test.script", "data": {"value": "{{ variable }}"}}, ] ) @@ -1965,6 +2170,7 @@ async def test_set_variable(hass, caplog): await hass.async_block_till_done() assert mock_calls[0].data["value"] == "value" + assert f"Executing step {alias}" in caplog.text async def test_set_redefines_variable(hass, caplog): diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 2916d616703..23d8200be23 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -118,6 +118,15 @@ def test_number_selector_schema(schema): selector.validate_selector({"number": schema}) +@pytest.mark.parametrize( + "schema", + ({},), +) +def test_addon_selector_schema(schema): + """Test add-on selector.""" + selector.validate_selector({"addon": schema}) + + @pytest.mark.parametrize( "schema", ({},), @@ -187,3 +196,26 @@ def test_object_selector_schema(schema): def test_text_selector_schema(schema): """Test text selector.""" selector.validate_selector({"text": schema}) + + +@pytest.mark.parametrize( + "schema", + ({"options": ["red", "green", "blue"]},), +) +def test_select_selector_schema(schema): + """Test select selector.""" + selector.validate_selector({"select": schema}) + + +@pytest.mark.parametrize( + "schema", + ( + {}, + {"options": {"hello": "World"}}, + {"options": []}, + ), +) +def test_select_selector_schema_error(schema): + """Test select selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"select": schema}) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 95ccdc84395..92cbd5514e6 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -195,6 +195,24 @@ class TestServiceHelpers(unittest.TestCase): "area_id": ["test-area-id"], } + config = { + "service": "{{ 'test_domain.test_service' }}", + "target": { + "area_id": ["area-42", "{{ 'area-51' }}"], + "device_id": ["abcdef", "{{ 'fedcba' }}"], + "entity_id": ["light.static", "{{ 'light.dynamic' }}"], + }, + } + + service.call_from_config(self.hass, config) + self.hass.block_till_done() + + assert dict(self.calls[1].data) == { + "area_id": ["area-42", "area-51"], + "device_id": ["abcdef", "fedcba"], + "entity_id": ["light.static", "light.dynamic"], + } + def test_service_template_service_call(self): """Test legacy service_template call with templating.""" config = { diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 174d61ea470..4259e7302ed 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -24,6 +24,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + def _set_up_units(hass): """Set up the tests.""" @@ -1470,6 +1472,79 @@ async def test_expand(hass): assert info.rate_limit is None +async def test_device_entities(hass): + """Test expand function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + + # Test non existing device ids + info = render_to_info(hass, "{{ device_entities('abc123') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ device_entities(56) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device without entities + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device with single entity, which has no state + entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, ["light.hue_5678"], []) + assert info.rate_limit is None + info = render_to_info( + hass, + f"{{{{ device_entities('{device_entry.id}') | expand | map(attribute='entity_id') | join(', ') }}}}", + ) + assert_result_info(info, "", ["light.hue_5678"]) + assert info.rate_limit is None + + # Test device with single entity, with state + hass.states.async_set("light.hue_5678", "happy") + info = render_to_info( + hass, + f"{{{{ device_entities('{device_entry.id}') | expand | map(attribute='entity_id') | join(', ') }}}}", + ) + assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) + assert info.rate_limit is None + + # Test device with multiple entities, which have a state + entity_registry.async_get_or_create( + "light", + "hue", + "ABCD", + config_entry=config_entry, + device_id=device_entry.id, + ) + hass.states.async_set("light.hue_abcd", "camper") + info = render_to_info(hass, f"{{{{ device_entities('{device_entry.id}') }}}}") + assert_result_info(info, ["light.hue_5678", "light.hue_abcd"], []) + assert info.rate_limit is None + info = render_to_info( + hass, + f"{{{{ device_entities('{device_entry.id}') | expand | map(attribute='entity_id') | join(', ') }}}}", + ) + assert_result_info( + info, "light.hue_5678, light.hue_abcd", ["light.hue_5678", "light.hue_abcd"] + ) + assert info.rate_limit is None + + def test_closest_function_to_coord(hass): """Test closest function to coord.""" hass.states.async_set( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 6eaaee87af0..ea6048dfc9e 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,5 +1,4 @@ """Test check_config script.""" -import logging from unittest.mock import patch import pytest @@ -9,8 +8,6 @@ import homeassistant.scripts.check_config as check_config from tests.common import get_test_config_dir, patch_yaml_files -_LOGGER = logging.getLogger(__name__) - BASE_CONFIG = ( "homeassistant:\n" " name: Home\n" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index fc653c25d0b..c035f6f1d1d 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -71,6 +71,7 @@ async def test_load_hassio(hass): assert bootstrap._get_domains(hass, {}) == {"hassio"} +@pytest.mark.parametrize("load_registries", [False]) async def test_empty_setup(hass): """Test an empty set up loads the core.""" await bootstrap.async_from_config_dict({}, hass) @@ -91,6 +92,7 @@ async def test_core_failure_loads_safe_mode(hass, caplog): assert "group" not in hass.config.components +@pytest.mark.parametrize("load_registries", [False]) async def test_setting_up_config(hass): """Test we set up domains in config.""" await bootstrap._async_set_up_integrations( @@ -100,6 +102,7 @@ async def test_setting_up_config(hass): assert "group" in hass.config.components +@pytest.mark.parametrize("load_registries", [False]) async def test_setup_after_deps_all_present(hass): """Test after_dependencies when all present.""" order = [] @@ -144,6 +147,7 @@ async def test_setup_after_deps_all_present(hass): assert order == ["logger", "root", "first_dep", "second_dep"] +@pytest.mark.parametrize("load_registries", [False]) async def test_setup_after_deps_in_stage_1_ignored(hass): """Test after_dependencies are ignored in stage 1.""" # This test relies on this @@ -190,6 +194,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass): assert order == ["cloud", "an_after_dep", "normal_integration"] +@pytest.mark.parametrize("load_registries", [False]) async def test_setup_after_deps_via_platform(hass): """Test after_dependencies set up via platform.""" order = [] @@ -239,6 +244,7 @@ async def test_setup_after_deps_via_platform(hass): assert order == ["after_dep_of_platform_int", "platform_int"] +@pytest.mark.parametrize("load_registries", [False]) async def test_setup_after_deps_not_trigger_load(hass): """Test after_dependencies does not trigger loading it.""" order = [] @@ -277,6 +283,7 @@ async def test_setup_after_deps_not_trigger_load(hass): assert "second_dep" in hass.config.components +@pytest.mark.parametrize("load_registries", [False]) async def test_setup_after_deps_not_present(hass): """Test after_dependencies when referenced integration doesn't exist.""" order = [] diff --git a/tests/test_config.py b/tests/test_config.py index 7dd7d61e8ef..299cf9caa73 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1088,6 +1088,26 @@ async def test_component_config_exceptions(hass, caplog): in caplog.text ) + # get_component raising + caplog.clear() + assert ( + await config_util.async_process_component_config( + hass, + {"test_domain": {}}, + integration=Mock( + pkg_path="homeassistant.components.test_domain", + domain="test_domain", + get_component=Mock( + side_effect=FileNotFoundError( + "No such file or directory: b'liblibc.a'" + ) + ), + ), + ) + is None + ) + assert "Unable to import test_domain: No such file or directory" in caplog.text + @pytest.mark.parametrize( "domain, schema, expected", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 24444f6a6c0..8a479a802e4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1108,6 +1108,110 @@ async def test_entry_reload_error(hass, manager, state): assert entry.state == state +async def test_entry_disable_succeed(hass, manager): + """Test that we can disable an entry.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ENTRY_STATE_LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + # Disable + assert await manager.async_set_disabled_by( + entry.entry_id, config_entries.DISABLED_USER + ) + assert len(async_unload_entry.mock_calls) == 1 + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + # Enable + assert await manager.async_set_disabled_by(entry.entry_id, None) + assert len(async_unload_entry.mock_calls) == 1 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +async def test_entry_disable_without_reload_support(hass, manager): + """Test that we can disable an entry without reload support.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ENTRY_STATE_LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + # Disable + assert not await manager.async_set_disabled_by( + entry.entry_id, config_entries.DISABLED_USER + ) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state == config_entries.ENTRY_STATE_FAILED_UNLOAD + + # Enable + with pytest.raises(config_entries.OperationNotAllowed): + await manager.async_set_disabled_by(entry.entry_id, None) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state == config_entries.ENTRY_STATE_FAILED_UNLOAD + + +async def test_entry_enable_without_reload_support(hass, manager): + """Test that we can disable an entry without reload support.""" + entry = MockConfigEntry(domain="comp", disabled_by=config_entries.DISABLED_USER) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + # Enable + assert await manager.async_set_disabled_by(entry.entry_id, None) + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + # Disable + assert not await manager.async_set_disabled_by( + entry.entry_id, config_entries.DISABLED_USER + ) + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_FAILED_UNLOAD + + async def test_init_custom_integration(hass): """Test initializing flow for custom integration.""" integration = loader.Integration( @@ -1585,6 +1689,45 @@ async def test_manual_add_overrides_ignored_entry(hass, manager): assert len(async_reload.mock_calls) == 0 +async def test_manual_add_overrides_ignored_entry_singleton(hass, manager): + """Test that we can ignore manually add entry, overriding ignored entry.""" + hass.config.components.add("comp") + entry = MockConfigEntry( + domain="comp", + state=config_entries.ENTRY_STATE_LOADED, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="title", data={"token": "supersecret"}) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry = mock_setup_entry.mock_calls[0][1] + + assert p_hass is hass + assert p_entry.data == {"token": "supersecret"} + + async def test_unignore_step_form(hass, manager): """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) diff --git a/tests/test_core.py b/tests/test_core.py index 0bf00d92c45..88b4e1d58f6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -379,6 +379,35 @@ async def test_eventbus_add_remove_listener(hass): unsub() +async def test_eventbus_filtered_listener(hass): + """Test we can prefilter events.""" + calls = [] + + @ha.callback + def listener(event): + """Mock listener.""" + calls.append(event) + + @ha.callback + def filter(event): + """Mock filter.""" + return not event.data["filtered"] + + unsub = hass.bus.async_listen("test", listener, event_filter=filter) + + hass.bus.async_fire("test", {"filtered": True}) + await hass.async_block_till_done() + + assert len(calls) == 0 + + hass.bus.async_fire("test", {"filtered": False}) + await hass.async_block_till_done() + + assert len(calls) == 1 + + unsub() + + async def test_eventbus_unsubscribe_listener(hass): """Test unsubscribe listener from returned function.""" calls = [] @@ -1294,41 +1323,6 @@ def test_valid_entity_id(): assert ha.valid_entity_id(valid), valid -async def test_migration_base_url(hass, hass_storage): - """Test that we migrate base url to internal/external url.""" - config = ha.Config(hass) - stored = {"version": 1, "data": {}} - hass_storage[ha.CORE_STORAGE_KEY] = stored - with patch.object(hass.bus, "async_listen_once") as mock_listen: - # Empty config - await config.async_load() - assert len(mock_listen.mock_calls) == 0 - - # With just a name - stored["data"] = {"location_name": "Test Name"} - await config.async_load() - assert len(mock_listen.mock_calls) == 1 - - # With external url - stored["data"]["external_url"] = "https://example.com" - await config.async_load() - assert len(mock_listen.mock_calls) == 1 - - # Test that the event listener works - assert mock_listen.mock_calls[0][1][0] == EVENT_HOMEASSISTANT_START - - # External - hass.config.api = Mock(deprecated_base_url="https://loaded-example.com") - await mock_listen.mock_calls[0][1][1](None) - assert config.external_url == "https://loaded-example.com" - - # Internal - for internal in ("http://hass.local", "http://192.168.1.100:8123"): - hass.config.api = Mock(deprecated_base_url=internal) - await mock_listen.mock_calls[0][1][1](None) - assert config.internal_url == internal - - async def test_additional_data_in_core_config(hass, hass_storage): """Test that we can handle additional data in core configuration.""" config = ha.Config(hass) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000000..959f0846cae --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,46 @@ +"""Test to verify that Home Assistant exceptions work.""" +from homeassistant.exceptions import ( + ConditionErrorContainer, + ConditionErrorIndex, + ConditionErrorMessage, +) + + +def test_conditionerror_format(): + """Test ConditionError stringifiers.""" + error1 = ConditionErrorMessage("test", "A test error") + assert str(error1) == "In 'test' condition: A test error" + + error2 = ConditionErrorMessage("test", "Another error") + assert str(error2) == "In 'test' condition: Another error" + + error_pos1 = ConditionErrorIndex("box", index=0, total=2, error=error1) + assert ( + str(error_pos1) + == """In 'box' (item 1 of 2): + In 'test' condition: A test error""" + ) + + error_pos2 = ConditionErrorIndex("box", index=1, total=2, error=error2) + assert ( + str(error_pos2) + == """In 'box' (item 2 of 2): + In 'test' condition: Another error""" + ) + + error_container1 = ConditionErrorContainer("box", errors=[error_pos1, error_pos2]) + print(error_container1) + assert ( + str(error_container1) + == """In 'box' (item 1 of 2): + In 'test' condition: A test error +In 'box' (item 2 of 2): + In 'test' condition: Another error""" + ) + + error_pos3 = ConditionErrorIndex("box", index=0, total=1, error=error1) + assert ( + str(error_pos3) + == """In 'box': + In 'test' condition: A test error""" + ) diff --git a/tests/test_loader.py b/tests/test_loader.py index c1c27f56cb7..8acc8a7de4f 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -130,11 +130,67 @@ async def test_custom_component_name(hass): async def test_log_warning_custom_component(hass, caplog): """Test that we log a warning when loading a custom component.""" - hass.components.test_standalone - assert "You are using a custom integration for test_standalone" in caplog.text + await loader.async_get_integration(hass, "test_standalone") + assert "You are using a custom integration test_standalone" in caplog.text await loader.async_get_integration(hass, "test") - assert "You are using a custom integration for test " in caplog.text + assert "You are using a custom integration test " in caplog.text + + +async def test_custom_integration_missing_version(hass, caplog): + """Test that we log a warning when custom integrations are missing a version.""" + test_integration_1 = loader.Integration( + hass, "custom_components.test1", None, {"domain": "test1"} + ) + test_integration_2 = loader.Integration( + hass, + "custom_components.test2", + None, + loader.manifest_from_legacy_module("test2", "custom_components.test2"), + ) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test1": test_integration_1, + "test2": test_integration_2, + } + + await loader.async_get_integration(hass, "test1") + assert ( + "No 'version' key in the manifest file for custom integration 'test1'." + in caplog.text + ) + + await loader.async_get_integration(hass, "test2") + assert ( + "No 'version' key in the manifest file for custom integration 'test2'." + in caplog.text + ) + + +async def test_no_version_warning_for_none_custom_integrations(hass, caplog): + """Test that we do not log a warning when core integrations are missing a version.""" + await loader.async_get_integration(hass, "hue") + assert ( + "No 'version' key in the manifest file for custom integration 'hue'." + not in caplog.text + ) + + +async def test_custom_integration_version_not_valid(hass, caplog): + """Test that we log a warning when custom integrations have a invalid version.""" + test_integration = loader.Integration( + hass, "custom_components.test", None, {"domain": "test", "version": "test"} + ) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = {"test": test_integration} + + await loader.async_get_integration(hass, "test") + assert ( + "'test' is not a valid version for custom integration 'test'." + in caplog.text + ) async def test_get_integration(hass): @@ -154,7 +210,6 @@ async def test_get_integration_legacy(hass): async def test_get_integration_custom_component(hass, enable_custom_integrations): """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_package") - print(integration) assert integration.get_component().DOMAIN == "test_package" assert integration.name == "Test Package" @@ -189,6 +244,7 @@ def test_integration_properties(hass): {"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}, ], "mqtt": ["hue/discovery"], + "version": "1.0.0", }, ) assert integration.name == "Philips Hue" @@ -215,6 +271,7 @@ def test_integration_properties(hass): assert integration.dependencies == ["test-dep"] assert integration.requirements == ["test-req==1.0.0"] assert integration.is_built_in is True + assert integration.version == "1.0.0" integration = loader.Integration( hass, @@ -233,6 +290,7 @@ def test_integration_properties(hass): assert integration.dhcp is None assert integration.ssdp is None assert integration.mqtt is None + assert integration.version is None integration = loader.Integration( hass, diff --git a/tests/util/test_percentage.py b/tests/util/test_percentage.py new file mode 100644 index 00000000000..4ad28f8567c --- /dev/null +++ b/tests/util/test_percentage.py @@ -0,0 +1,158 @@ +"""Test Home Assistant percentage conversions.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +SPEED_LOW = "low" +SPEED_MEDIUM = "medium" +SPEED_HIGH = "high" + +SPEED_1 = SPEED_LOW +SPEED_2 = SPEED_MEDIUM +SPEED_3 = SPEED_HIGH +SPEED_4 = "very_high" +SPEED_5 = "storm" +SPEED_6 = "hurricane" +SPEED_7 = "solar_wind" + +LEGACY_ORDERED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] +SMALL_ORDERED_LIST = [SPEED_1, SPEED_2, SPEED_3, SPEED_4] +LARGE_ORDERED_LIST = [SPEED_1, SPEED_2, SPEED_3, SPEED_4, SPEED_5, SPEED_6, SPEED_7] + + +async def test_ordered_list_percentage_round_trip(): + """Test we can round trip.""" + for ordered_list in (SMALL_ORDERED_LIST, LARGE_ORDERED_LIST): + for i in range(1, 100): + ordered_list_item_to_percentage( + ordered_list, percentage_to_ordered_list_item(ordered_list, i) + ) == i + + +async def test_ordered_list_item_to_percentage(): + """Test percentage of an item in an ordered list.""" + + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_LOW) == 33 + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_MEDIUM) == 66 + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_HIGH) == 100 + + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_1) == 25 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_2) == 50 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_3) == 75 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_4) == 100 + + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_1) == 14 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_2) == 28 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_3) == 42 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_4) == 57 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_5) == 71 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_6) == 85 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_7) == 100 + + with pytest.raises(ValueError): + assert ordered_list_item_to_percentage([], SPEED_1) + + +async def test_percentage_to_ordered_list_item(): + """Test item that most closely matches the percentage in an ordered list.""" + + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 25) == SPEED_1 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 50) == SPEED_2 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 51) == SPEED_3 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 75) == SPEED_3 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 76) == SPEED_4 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 100) == SPEED_4 + + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 17) == SPEED_LOW + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 33) == SPEED_LOW + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 50) == SPEED_MEDIUM + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 66) == SPEED_MEDIUM + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 84) == SPEED_HIGH + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 100) == SPEED_HIGH + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 14) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 25) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 28) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 29) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 41) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 42) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 43) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 56) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 50) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 51) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 75) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 76) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100) == SPEED_7 + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 25) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 50) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 51) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 75) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 76) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100) == SPEED_7 + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100.1) == SPEED_7 + + with pytest.raises(ValueError): + assert percentage_to_ordered_list_item([], 100) + + +async def test_ranged_value_to_percentage_large(): + """Test a large range of low and high values convert a single value to a percentage.""" + range = (1, 255) + + assert ranged_value_to_percentage(range, 255) == 100 + assert ranged_value_to_percentage(range, 127) == 49 + assert ranged_value_to_percentage(range, 10) == 3 + assert ranged_value_to_percentage(range, 1) == 0 + + +async def test_percentage_to_ranged_value_large(): + """Test a large range of low and high values convert a percentage to a single value.""" + range = (1, 255) + + assert percentage_to_ranged_value(range, 100) == 255 + assert percentage_to_ranged_value(range, 50) == 127.5 + assert percentage_to_ranged_value(range, 4) == 10.2 + + assert math.ceil(percentage_to_ranged_value(range, 100)) == 255 + assert math.ceil(percentage_to_ranged_value(range, 50)) == 128 + assert math.ceil(percentage_to_ranged_value(range, 4)) == 11 + + +async def test_ranged_value_to_percentage_small(): + """Test a small range of low and high values convert a single value to a percentage.""" + range = (1, 6) + + assert ranged_value_to_percentage(range, 1) == 16 + assert ranged_value_to_percentage(range, 2) == 33 + assert ranged_value_to_percentage(range, 3) == 50 + assert ranged_value_to_percentage(range, 4) == 66 + assert ranged_value_to_percentage(range, 5) == 83 + assert ranged_value_to_percentage(range, 6) == 100 + + +async def test_percentage_to_ranged_value_small(): + """Test a small range of low and high values convert a percentage to a single value.""" + range = (1, 6) + + assert math.ceil(percentage_to_ranged_value(range, 16)) == 1 + assert math.ceil(percentage_to_ranged_value(range, 33)) == 2 + assert math.ceil(percentage_to_ranged_value(range, 50)) == 3 + assert math.ceil(percentage_to_ranged_value(range, 66)) == 4 + assert math.ceil(percentage_to_ranged_value(range, 83)) == 5 + assert math.ceil(percentage_to_ranged_value(range, 100)) == 6 diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 34097287bc3..b3a8ca4e486 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,6 +1,5 @@ """Test Home Assistant yaml loader.""" import io -import logging import os import unittest from unittest.mock import patch @@ -15,14 +14,6 @@ from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files -@pytest.fixture(autouse=True) -def mock_credstash(): - """Mock credstash so it doesn't connect to the internet.""" - with patch.object(yaml_loader, "credstash") as mock_credstash: - mock_credstash.getSecret.return_value = None - yield mock_credstash - - def test_simple_list(): """Test simple list.""" conf = "config:\n - simple\n - list" @@ -294,20 +285,6 @@ def load_yaml(fname, string): return load_yaml_config_file(fname) -class FakeKeyring: - """Fake a keyring class.""" - - def __init__(self, secrets_dict): - """Store keyring dictionary.""" - self._secrets = secrets_dict - - # pylint: disable=protected-access - def get_password(self, domain, name): - """Retrieve password.""" - assert domain == yaml._SECRET_NAMESPACE - return self._secrets.get(name) - - class TestSecrets(unittest.TestCase): """Test the secrets parameter in the yaml utility.""" @@ -395,27 +372,6 @@ class TestSecrets(unittest.TestCase): "http:\n api_password: !secret test", ) - def test_secrets_keyring(self): - """Test keyring fallback & get_password.""" - yaml_loader.keyring = None # Ensure its not there - yaml_str = "http:\n api_password: !secret http_pw_keyring" - with pytest.raises(HomeAssistantError): - load_yaml(self._yaml_path, yaml_str) - - yaml_loader.keyring = FakeKeyring({"http_pw_keyring": "yeah"}) - _yaml = load_yaml(self._yaml_path, yaml_str) - assert {"http": {"api_password": "yeah"}} == _yaml - - @patch.object(yaml_loader, "credstash") - def test_secrets_credstash(self, mock_credstash): - """Test credstash fallback & get_password.""" - mock_credstash.getSecret.return_value = "yeah" - yaml_str = "http:\n api_password: !secret http_pw_credstash" - _yaml = load_yaml(self._yaml_path, yaml_str) - log = logging.getLogger() - log.error(_yaml["http"]) - assert {"api_password": "yeah"} == _yaml["http"] - def test_secrets_logger_removed(self): """Ensure logger: debug was removed.""" with pytest.raises(HomeAssistantError): @@ -463,6 +419,17 @@ def test_duplicate_key(caplog): assert "contains duplicate key" in caplog.text +def test_no_recursive_secrets(caplog): + """Test that loading of secrets from the secrets file fails correctly.""" + files = {YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"} + with patch_yaml_files(files), pytest.raises(HomeAssistantError) as e: + load_yaml_config_file(YAML_CONFIG_FILE) + assert e.value.args == ( + "secrets.yaml: attempt to load secret from within secrets file", + ) + assert "attempt to load secret from within secrets file" in caplog.text + + def test_input_class(): """Test input class.""" input = yaml_loader.Input("hello") diff --git a/tox.ini b/tox.ini index 9c9963c28ee..a82c5afda79 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,8 @@ ignore_basepython_conflict = True [testenv] basepython = {env:PYTHON3_PATH:python3} +# pip version duplicated in homeassistant/package_constraints.txt +pip_version = pip>=8.0.3,<20.3 commands = pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar {posargs} {toxinidir}/script/check_dirty