diff --git a/.circleci/config.yml b/.circleci/config.yml index e424f4c42cb..294b5ab1db9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -57,7 +57,7 @@ commands: <<# parameters.all >>pip install -q --progress-bar off -r requirements_all.txt -c homeassistant/package_constraints.txt<> <<# parameters.test >>pip install -q --progress-bar off -r requirements_test.txt -c homeassistant/package_constraints.txt<> <<# parameters.test_all >>pip install -q --progress-bar off -r requirements_test_all.txt -c homeassistant/package_constraints.txt<> - no_output_timeout: 15m + no_output_timeout: 15m - save_cache: paths: - ./venv @@ -90,7 +90,7 @@ jobs: name: run static check command: | . venv/bin/activate - flake8 + flake8 homeassistant tests script - run: name: run static type check diff --git a/.codecov.yml b/.codecov.yml index 9ad9083506d..be739b61809 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -13,3 +13,4 @@ coverage: url: "secret:TgWDUM4Jw0w7wMJxuxNF/yhSOHglIo1fGwInJnRLEVPy2P2aLimkoK1mtKCowH5TFw+baUXVXT3eAqefbdvIuM8BjRR4aRji95C6CYyD0QHy4N8i7nn1SQkWDPpS8IthYTg07rUDF7s5guurkKv2RrgoCdnnqjAMSzHoExMOF7xUmblMdhBTWJgBpWEhASJy85w/xxjlsE1xoTkzeJu9Q67pTXtRcn+5kb5/vIzPSYg=" comment: require_changes: yes + branches: master diff --git a/.coveragerc b/.coveragerc index 86819ef51a3..2b5f328466c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -22,6 +22,7 @@ omit = homeassistant/components/alarmdotcom/alarm_control_panel.py homeassistant/components/alpha_vantage/sensor.py homeassistant/components/amazon_polly/tts.py + homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/* homeassistant/components/amcrest/* homeassistant/components/ampio/* @@ -52,6 +53,7 @@ omit = homeassistant/components/bbox/sensor.py homeassistant/components/bh1750/sensor.py homeassistant/components/bitcoin/sensor.py + homeassistant/components/bizkaibus/sensor.py homeassistant/components/blink/* homeassistant/components/blinksticklight/light.py homeassistant/components/blinkt/light.py @@ -173,6 +175,7 @@ omit = homeassistant/components/esphome/light.py homeassistant/components/esphome/sensor.py homeassistant/components/esphome/switch.py + homeassistant/components/essent/sensor.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/everlights/light.py @@ -275,9 +278,11 @@ omit = homeassistant/components/imap_email_content/sensor.py homeassistant/components/influxdb/sensor.py homeassistant/components/insteon/* + homeassistant/components/incomfort/* homeassistant/components/ios/* homeassistant/components/iota/* homeassistant/components/iperf3/* + homeassistant/components/iqvia/* homeassistant/components/irish_rail_transport/sensor.py homeassistant/components/iss/binary_sensor.py homeassistant/components/isy994/* @@ -344,6 +349,7 @@ omit = homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/meteo_france/* + homeassistant/components/meteoalarm/* homeassistant/components/metoffice/sensor.py homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py @@ -418,6 +424,7 @@ omit = homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py homeassistant/components/opple/light.py + homeassistant/components/orangepi_gpio/* homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py @@ -440,7 +447,6 @@ omit = homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py homeassistant/components/point/* - homeassistant/components/pollen/sensor.py homeassistant/components/postnl/sensor.py homeassistant/components/prezzibenzina/sensor.py homeassistant/components/proliphix/climate.py @@ -449,6 +455,7 @@ omit = homeassistant/components/proxy/camera.py homeassistant/components/ps4/__init__.py homeassistant/components/ps4/media_player.py + homeassistant/components/ptvsd/* homeassistant/components/pulseaudio_loopback/switch.py homeassistant/components/pushbullet/notify.py homeassistant/components/pushbullet/sensor.py @@ -534,9 +541,7 @@ omit = homeassistant/components/smappee/* homeassistant/components/smtp/notify.py homeassistant/components/snapcast/media_player.py - homeassistant/components/snmp/device_tracker.py - homeassistant/components/snmp/sensor.py - homeassistant/components/snmp/switch.py + homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py homeassistant/components/solaredge/sensor.py @@ -561,6 +566,7 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py + homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthru/sensor.py homeassistant/components/synology/camera.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ebebf487275..474dff86b3d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ **Related issue (if applicable):** fixes # -**Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io# +**Pull request with documentation for [home-assistant.io](https://github.com/home-assistant/home-assistant.io) (if applicable):** home-assistant/home-assistant.io# ## Example entry for `configuration.yaml` (if applicable): ```yaml @@ -18,21 +18,18 @@ - [ ] The code change is tested and works locally. - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. + - [ ] I have followed the [development checklist][dev-checklist] If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) If the code communicates with devices, web services, or third-party tools: - - [ ] [_The manifest file_][manifest-docs] has all fields filled out correctly ([example][ex-manifest]). - - [ ] New dependencies have been added to `requirements` in the manifest ([example][ex-requir]). - - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]). - - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. - - [ ] New files were added to `.coveragerc`. + - [ ] [_The manifest file_][manifest-docs] has all fields filled out correctly. Update and include derived files by running `python3 -m script.hassfest`. + - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `python3 -m script.gen_requirements_all`. + - [ ] Untested files have been added to `.coveragerc`. If the code does not interact with devices: - [ ] Tests have been added to verify that the new code works. -[ex-manifest]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/mobile_app/manifest.json -[ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/mobile_app/manifest.json#L5 -[ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard/__init__.py#L23 -[manifest-docs]: https://developers.home-assistant.io/docs/en/development_checklist.html#_the-manifest-file_ +[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html +[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html diff --git a/.github/main.workflow b/.github/main.workflow deleted file mode 100644 index 62336ebf126..00000000000 --- a/.github/main.workflow +++ /dev/null @@ -1,14 +0,0 @@ -workflow "Mention CODEOWNERS of integrations when integration label is added to an issue" { - on = "issues" - resolves = "codeowners-mention" -} - -workflow "Mention CODEOWNERS of integrations when integration label is added to an PRs" { - on = "pull_request" - resolves = "codeowners-mention" -} - -action "codeowners-mention" { - uses = "home-assistant/codeowners-mention@master" - secrets = ["GITHUB_TOKEN"] -} diff --git a/.gitignore b/.gitignore index b486032c741..7a0cb29bc2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ config/* +config2/* tests/testing_config/deps tests/testing_config/home-assistant.log @@ -84,7 +85,7 @@ Scripts/ # vimmy stuff *.swp *.swo - +tags ctags.tmp # vagrant stuff diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..4167b1c9923 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,33 @@ +sudo: false +dist: xenial +addons: + apt: + sources: + - sourceline: "ppa:jonathonf/ffmpeg-4" + packages: + - libudev-dev + - libavformat-dev + - libavcodec-dev + - libavdevice-dev + - libavutil-dev + - libswscale-dev + - libswresample-dev + - libavfilter-dev +matrix: + fast_finish: true + include: + - python: "3.5.3" + env: TOXENV=lint + - python: "3.5.3" + env: TOXENV=pylint + - python: "3.5.3" + env: TOXENV=typing + - python: "3.5.3" + env: TOXENV=py35 + - python: "3.7" + env: TOXENV=py37 + +cache: pip +install: pip install -U tox +language: python +script: travis_wait 40 tox --develop diff --git a/CODEOWNERS b/CODEOWNERS index 30269be9051..90fb72378bc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -21,6 +21,7 @@ homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 +homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya homeassistant/components/api/* @home-assistant/core homeassistant/components/arduino/* @fabaff @@ -32,6 +33,7 @@ homeassistant/components/automation/* @home-assistant/core homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 homeassistant/components/bitcoin/* @fabaff +homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blink/* @fronzbot homeassistant/components/bmw_connected_drive/* @ChristianKuehnel homeassistant/components/braviatv/* @robbiet480 @@ -66,10 +68,13 @@ homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/emby/* @mezz64 homeassistant/components/enigma2/* @fbradyirl +homeassistant/components/enocean/* @bdurrer homeassistant/components/ephember/* @ttroy50 homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter +homeassistant/components/essent/* @TheLastProject +homeassistant/components/evohome/* @zxdavb homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes homeassistant/components/fitbit/* @robbiet480 @@ -80,6 +85,7 @@ homeassistant/components/foursquare/* @robbiet480 homeassistant/components/freebox/* @snoof85 homeassistant/components/frontend/* @home-assistant/core homeassistant/components/gearbest/* @HerrHofrat +homeassistant/components/geniushub/* @zxdavb homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff homeassistant/components/gntp/* @robbiet480 @@ -99,6 +105,7 @@ homeassistant/components/history_graph/* @andrey-git homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @cdce8p +homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 homeassistant/components/html5/* @robbiet480 homeassistant/components/http/* @home-assistant/core @@ -106,6 +113,7 @@ homeassistant/components/huawei_lte/* @scop homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob homeassistant/components/ign_sismologia/* @exxamalte +homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff homeassistant/components/input_boolean/* @home-assistant/core homeassistant/components/input_datetime/* @home-assistant/core @@ -115,6 +123,7 @@ homeassistant/components/input_text/* @home-assistant/core homeassistant/components/integration/* @dgomes homeassistant/components/ios/* @robbiet480 homeassistant/components/ipma/* @dgomes +homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/jewish_calendar/* @tsvi homeassistant/components/knx/* @Julius2342 @@ -137,6 +146,7 @@ homeassistant/components/matrix/* @tinloaf homeassistant/components/mediaroom/* @dgomes homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen +homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff @@ -150,24 +160,28 @@ homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan homeassistant/components/netdata/* @fabaff +homeassistant/components/nextbus/* @vividboarder homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff -homeassistant/components/notify/* @flowolf +homeassistant/components/notify/* @home-assistant/core homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nuki/* @pschmitt homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/onboarding/* @home-assistant/core homeassistant/components/openuv/* @bachya homeassistant/components/openweathermap/* @fabaff +homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/owlet/* @oblogic7 homeassistant/components/panel_custom/* @home-assistant/core homeassistant/components/panel_iframe/* @home-assistant/core homeassistant/components/persistent_notification/* @home-assistant/core +homeassistant/components/philips_js/* @elupus homeassistant/components/pi_hole/* @fabaff homeassistant/components/plant/* @ChristianKuehnel homeassistant/components/point/* @fredrike -homeassistant/components/pollen/* @bachya +homeassistant/components/ps4/* @ktnrg45 +homeassistant/components/ptvsd/* @swamp-ig homeassistant/components/push/* @dgomes homeassistant/components/pvoutput/* @fabaff homeassistant/components/qnap/* @colinodell @@ -204,7 +218,9 @@ homeassistant/components/supla/* @mwegrzynek homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff homeassistant/components/switchbot/* @danielhiversen +homeassistant/components/switcher_kis/* @tomerfi homeassistant/components/switchmate/* @danielhiversen +homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/sytadin/* @gautric @@ -235,6 +251,7 @@ homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/utility_meter/* @dgomes homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff +homeassistant/components/vizio/* @raman325 homeassistant/components/waqi/* @andrey-git homeassistant/components/weather/* @fabaff homeassistant/components/weblink/* @home-assistant/core @@ -245,7 +262,7 @@ homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi homeassistant/components/xiaomi_miio/* @rytilahti @syssi homeassistant/components/xiaomi_tv/* @fattdev -homeassistant/components/xmpp/* @fabaff +homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000000..fd45c334cf3 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,186 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev + tags: + include: + - '*' + +variables: + - name: versionBuilder + value: '3.2' + - name: versionWheels + value: '0.3' + - group: docker + - group: wheels + - group: github + +jobs: + +- job: 'Wheels' + condition: eq(variables['Build.SourceBranchName'], 'dev') + timeoutInMinutes: 360 + pool: + vmImage: 'ubuntu-16.04' + strategy: + maxParallel: 3 + matrix: + amd64: + buildArch: 'amd64' + i386: + buildArch: 'i386' + armhf: + buildArch: 'armhf' + armv7: + buildArch: 'armv7' + aarch64: + buildArch: 'aarch64' + steps: + - script: | + sudo apt-get install -y --no-install-recommends \ + qemu-user-static \ + binfmt-support + + sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc + sudo update-binfmts --enable qemu-arm + sudo update-binfmts --enable qemu-aarch64 + displayName: 'Initial cross build' + - script: | + mkdir -p .ssh + echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa + ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts + chmod 600 .ssh/* + displayName: 'Install ssh key' + - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) + displayName: 'Install wheels builder' + - script: | + cp requirements_all.txt requirements_hassio.txt + + # Enable because we can build it + sed -i "s|# pytradfri|pytradfri|g" requirements_hassio.txt + sed -i "s|# pybluez|pybluez|g" requirements_hassio.txt + sed -i "s|# bluepy|bluepy|g" requirements_hassio.txt + sed -i "s|# beacontools|beacontools|g" requirements_hassio.txt + sed -i "s|# RPi.GPIO|RPi.GPIO|g" requirements_hassio.txt + sed -i "s|# raspihats|raspihats|g" requirements_hassio.txt + sed -i "s|# rpi-rf|rpi-rf|g" requirements_hassio.txt + sed -i "s|# blinkt|blinkt|g" requirements_hassio.txt + sed -i "s|# fritzconnection|fritzconnection|g" requirements_hassio.txt + sed -i "s|# pyuserinput|pyuserinput|g" requirements_hassio.txt + sed -i "s|# evdev|evdev|g" requirements_hassio.txt + sed -i "s|# smbus-cffi|smbus-cffi|g" requirements_hassio.txt + sed -i "s|# i2csense|i2csense|g" requirements_hassio.txt + sed -i "s|# python-eq3bt|python-eq3bt|g" requirements_hassio.txt + sed -i "s|# pycups|pycups|g" requirements_hassio.txt + sed -i "s|# homekit|homekit|g" requirements_hassio.txt + sed -i "s|# decora_wifi|decora_wifi|g" requirements_hassio.txt + sed -i "s|# decora|decora|g" requirements_hassio.txt + sed -i "s|# PySwitchbot|PySwitchbot|g" requirements_hassio.txt + sed -i "s|# pySwitchmate|pySwitchmate|g" requirements_hassio.txt + + # Disable because of error + sed -i "s|insteonplm|# insteonplm|g" requirements_hassio.txt + displayName: 'Prepare requirements files for Hass.io' + - script: | + sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ + homeassistant/$(buildArch)-wheels:$(versionWheels) \ + --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ + --index https://wheels.hass.io \ + --requirement requirements_hassio.txt \ + --upload rsync \ + --remote wheels@$(wheelsHost):/opt/wheels + displayName: 'Run wheels build' + + +- job: 'Release' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + timeoutInMinutes: 120 + pool: + vmImage: 'ubuntu-16.04' + strategy: + maxParallel: 5 + matrix: + amd64: + buildArch: 'amd64' + buildMachine: 'qemux86-64,intel-nuc' + i386: + buildArch: 'i386' + buildMachine: 'qemux86' + armhf: + buildArch: 'armhf' + buildMachine: 'qemuarm,raspberrypi' + armv7: + buildArch: 'armv7' + buildMachine: 'raspberrypi2,raspberrypi3,odroid-xu,tinker' + aarch64: + buildArch: 'aarch64' + buildMachine: 'qemuarm-64,raspberrypi3-64,odroid-c2,orangepi-prime' + steps: + - script: sudo docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Docker hub login' + - script: sudo docker pull homeassistant/amd64-builder:$(versionBuilder) + displayName: 'Install Builder' + - script: | + set -e + + sudo docker run --rm --privileged \ + -v ~/.docker:/root/.docker \ + -v /run/docker.sock:/run/docker.sock:rw \ + homeassistant/amd64-builder:$(versionBuilder) \ + --homeassistant $(Build.SourceBranchName) "--$(buildArch)" \ + -r https://github.com/home-assistant/hassio-homeassistant \ + -t generic --docker-hub homeassistant + + sudo docker run --rm --privileged \ + -v ~/.docker:/root/.docker \ + -v /run/docker.sock:/run/docker.sock:rw \ + homeassistant/amd64-builder:$(versionBuilder) \ + --homeassistant-machine "$(Build.SourceBranchName)=$(buildMachine)" \ + -r https://github.com/home-assistant/hassio-homeassistant \ + -t machine --docker-hub homeassistant + displayName: 'Build Release' + + +- job: 'ReleasePublish' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('Release')) + dependsOn: + - 'Release' + pool: + vmImage: 'ubuntu-16.04' + steps: + - script: | + sudo apt-get install -y --no-install-recommends \ + git jq + + git config --global user.name "Pascal Vizeli" + git config --global user.email "pvizeli@syshack.ch" + git config --global credential.helper store + + echo "https://$(githubToken):x-oauth-basic@github.com > $HOME\.git-credentials + displayName: 'Install requirements' + - script: | + set -e + + version="$(Build.SourceBranchName)" + + git clone https://github.com/home-assistant/hassio-version + cd hassio-version + + dev_version="$(jq --raw-output '.homeassistant.default' dev.json)" + beta_version="$(jq --raw-output '.homeassistant.default' beta.json)" + stable_version="$(jq --raw-output '.homeassistant.default' stable.json)" + + if [[ "$version" =~ b ]]; then + sed -i "s|$dev_version|$version|g" dev.json + sed -i "s|$beta_version|$version|g" beta.json + else + sed -i "s|$dev_version|$version|g" dev.json + sed -i "s|$beta_version|$version|g" beta.json + sed -i "s|$stable_version|$version|g" stable.json + fi + + git commit -am "Bump Home Assistant $version" + git push diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index a424716f0aa..023faadef0c 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -7,8 +7,9 @@ import platform import subprocess import sys import threading -from typing import List, Dict, Any # noqa pylint: disable=unused-import - +from typing import ( # noqa pylint: disable=unused-import + List, Dict, Any, TYPE_CHECKING +) from homeassistant import monkey_patch from homeassistant.const import ( @@ -18,6 +19,9 @@ from homeassistant.const import ( RESTART_EXIT_CODE, ) +if TYPE_CHECKING: + from homeassistant import core + def set_loop() -> None: """Attempt to use uvloop.""" @@ -86,10 +90,12 @@ def ensure_config_path(config_dir: str) -> None: sys.exit(1) -def ensure_config_file(config_dir: str) -> str: +async def ensure_config_file(hass: 'core.HomeAssistant', config_dir: str) \ + -> str: """Ensure configuration file exists.""" import homeassistant.config as config_util - config_path = config_util.ensure_config_exists(config_dir) + config_path = await config_util.async_ensure_config_exists( + hass, config_dir) if config_path is None: print('Error getting configuration path') @@ -261,6 +267,7 @@ def cmdline() -> List[str]: async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: """Set up HASS and run.""" + # pylint: disable=redefined-outer-name from homeassistant import bootstrap, core hass = core.HomeAssistant() @@ -275,7 +282,7 @@ async def setup_and_run_hass(config_dir: str, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, log_file=args.log_file, log_no_color=args.log_no_color) else: - config_file = ensure_config_file(config_dir) + config_file = await ensure_config_file(hass, config_dir) print('Config directory:', config_dir) await bootstrap.async_from_config_file( config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip, @@ -390,7 +397,7 @@ def main() -> int: if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() - return exit_code # type: ignore # mypy cannot yet infer it + return exit_code # type: ignore if __name__ == "__main__": diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 310abff9484..396a0fb8d3f 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow -REQUIREMENTS = ['pyotp==2.2.6'] +REQUIREMENTS = ['pyotp==2.2.7'] CONF_MESSAGE = 'message' diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index dc51152f565..bb07d9e479f 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow -REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1'] +REQUIREMENTS = ['pyotp==2.2.7', 'PyQRCode==1.2.1'] CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 63e76dd2496..0079f11447b 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -11,6 +11,7 @@ from .models import PermissionLookup from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa +from .util import test_all POLICY_SCHEMA = vol.Schema({ @@ -29,6 +30,10 @@ class AbstractPermissions: """Return a function that can test entity access.""" raise NotImplementedError + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: """Check if we can access entity.""" entity_func = self._cached_entity_func @@ -48,6 +53,10 @@ class PolicyPermissions(AbstractPermissions): self._policy = policy self._perm_lookup = perm_lookup + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + return test_all(self._policy.get(CAT_ENTITIES), key) + def _entity_func(self) -> Callable[[str, str], bool]: """Return a function that can test entity access.""" return compile_entities(self._policy.get(CAT_ENTITIES), @@ -65,6 +74,10 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + return True + def _entity_func(self) -> Callable[[str, str], bool]: """Return a function that can test entity access.""" return lambda entity_id, key: True diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index d2d259fb32e..0d334c4a3ba 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -3,6 +3,7 @@ from functools import wraps from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401 +from .const import SUBCAT_ALL from .models import PermissionLookup from .types import CategoryType, SubCategoryDict, ValueType @@ -96,3 +97,16 @@ def _gen_dict_test_func( return schema.get(key) return test_value + + +def test_all(policy: CategoryType, key: str) -> bool: + """Test if a policy has an ALL access for a specific key.""" + if not isinstance(policy, dict): + return bool(policy) + + all_policy = policy.get(SUBCAT_ALL) + + if not isinstance(all_policy, dict): + return bool(all_policy) + + return all_policy.get(key, False) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3959eb88035..d63caf9e76f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -26,6 +26,7 @@ ERROR_LOG_FILENAME = 'home-assistant.log' # hass.data key for logging information. DATA_LOGGING = 'logging' +DEBUGGER_INTEGRATIONS = {'ptvsd', } CORE_INTEGRATIONS = ('homeassistant', 'persistent_notification') LOGGING_INTEGRATIONS = {'logger', 'system_log'} STAGE_1_INTEGRATIONS = { @@ -306,6 +307,15 @@ async def _async_set_up_integrations( """Set up all the integrations.""" domains = _get_domains(hass, config) + # Start up debuggers. Start these first in case they want to wait. + debuggers = domains & DEBUGGER_INTEGRATIONS + if debuggers: + _LOGGER.debug("Starting up debuggers %s", debuggers) + await asyncio.gather(*[ + async_setup_component(hass, domain, config) + for domain in debuggers]) + domains -= DEBUGGER_INTEGRATIONS + # Resolve all dependencies of all components so we can find the logging # and integrations that need faster initialization. resolved_domains_task = asyncio.gather(*[ @@ -339,7 +349,7 @@ async def _async_set_up_integrations( stage_2_domains = domains - logging_domains - stage_1_domains if logging_domains: - _LOGGER.debug("Setting up %s", logging_domains) + _LOGGER.info("Setting up %s", logging_domains) await asyncio.gather(*[ async_setup_component(hass, domain, config) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index ba49f8abd9a..920a2a034d7 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -31,9 +31,11 @@ CONF_ADS_TYPE = 'adstype' CONF_ADS_VALUE = 'value' CONF_ADS_VAR = 'adsvar' CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' +CONF_ADS_VAR_POSITION = 'adsvar_position' STATE_KEY_STATE = 'state' STATE_KEY_BRIGHTNESS = 'brightness' +STATE_KEY_POSITION = 'position' DOMAIN = 'ads' diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py new file mode 100644 index 00000000000..56576229981 --- /dev/null +++ b/homeassistant/components/ads/cover.py @@ -0,0 +1,165 @@ +"""Support for ADS covers.""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, + SUPPORT_SET_POSITION, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, + CoverDevice) +from homeassistant.const import ( + CONF_NAME, CONF_DEVICE_CLASS) +import homeassistant.helpers.config_validation as cv + +from . import CONF_ADS_VAR, CONF_ADS_VAR_POSITION, DATA_ADS, \ + AdsEntity, STATE_KEY_STATE, STATE_KEY_POSITION + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'ADS Cover' + +CONF_ADS_VAR_SET_POS = 'adsvar_set_position' +CONF_ADS_VAR_OPEN = 'adsvar_open' +CONF_ADS_VAR_CLOSE = 'adsvar_close' +CONF_ADS_VAR_STOP = 'adsvar_stop' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_ADS_VAR_POSITION): cv.string, + vol.Optional(CONF_ADS_VAR_SET_POS): cv.string, + vol.Optional(CONF_ADS_VAR_CLOSE): cv.string, + vol.Optional(CONF_ADS_VAR_OPEN): cv.string, + vol.Optional(CONF_ADS_VAR_STOP): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the cover platform for ADS.""" + ads_hub = hass.data[DATA_ADS] + + ads_var_is_closed = config.get(CONF_ADS_VAR) + ads_var_position = config.get(CONF_ADS_VAR_POSITION) + ads_var_pos_set = config.get(CONF_ADS_VAR_SET_POS) + ads_var_open = config.get(CONF_ADS_VAR_OPEN) + ads_var_close = config.get(CONF_ADS_VAR_CLOSE) + ads_var_stop = config.get(CONF_ADS_VAR_STOP) + name = config[CONF_NAME] + device_class = config.get(CONF_DEVICE_CLASS) + + add_entities([AdsCover(ads_hub, + ads_var_is_closed, + ads_var_position, + ads_var_pos_set, + ads_var_open, + ads_var_close, + ads_var_stop, + name, + device_class)]) + + +class AdsCover(AdsEntity, CoverDevice): + """Representation of ADS cover.""" + + def __init__(self, ads_hub, + ads_var_is_closed, ads_var_position, + ads_var_pos_set, ads_var_open, + ads_var_close, ads_var_stop, name, device_class): + """Initialize AdsCover entity.""" + super().__init__(ads_hub, name, ads_var_is_closed) + if self._ads_var is None: + if ads_var_position is not None: + self._unique_id = ads_var_position + elif ads_var_pos_set is not None: + self._unique_id = ads_var_pos_set + elif ads_var_open is not None: + self._unique_id = ads_var_open + + self._state_dict[STATE_KEY_POSITION] = None + self._ads_var_position = ads_var_position + self._ads_var_pos_set = ads_var_pos_set + self._ads_var_open = ads_var_open + self._ads_var_close = ads_var_close + self._ads_var_stop = ads_var_stop + self._device_class = device_class + + async def async_added_to_hass(self): + """Register device notification.""" + if self._ads_var is not None: + await self.async_initialize_device(self._ads_var, + self._ads_hub.PLCTYPE_BOOL) + + if self._ads_var_position is not None: + await self.async_initialize_device(self._ads_var_position, + self._ads_hub.PLCTYPE_BYTE, + STATE_KEY_POSITION) + + @property + def device_class(self): + """Return the class of this cover.""" + return self._device_class + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._ads_var is not None: + return self._state_dict[STATE_KEY_STATE] + if self._ads_var_position is not None: + return self._state_dict[STATE_KEY_POSITION] == 0 + return None + + @property + def current_cover_position(self): + """Return current position of cover.""" + return self._state_dict[STATE_KEY_POSITION] + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + + if self._ads_var_stop is not None: + supported_features |= SUPPORT_STOP + + if self._ads_var_pos_set is not None: + supported_features |= SUPPORT_SET_POSITION + + return supported_features + + def stop_cover(self, **kwargs): + """Fire the stop action.""" + if self._ads_var_stop: + self._ads_hub.write_by_name(self._ads_var_stop, True, + self._ads_hub.PLCTYPE_BOOL) + + def set_cover_position(self, **kwargs): + """Set cover position.""" + position = kwargs[ATTR_POSITION] + if self._ads_var_pos_set is not None: + self._ads_hub.write_by_name(self._ads_var_pos_set, position, + self._ads_hub.PLCTYPE_BYTE) + + def open_cover(self, **kwargs): + """Move the cover up.""" + if self._ads_var_open is not None: + self._ads_hub.write_by_name(self._ads_var_open, True, + self._ads_hub.PLCTYPE_BOOL) + elif self._ads_var_pos_set is not None: + self.set_cover_position(position=100) + + def close_cover(self, **kwargs): + """Move the cover down.""" + if self._ads_var_close is not None: + self._ads_hub.write_by_name(self._ads_var_close, True, + self._ads_hub.PLCTYPE_BOOL) + elif self._ads_var_pos_set is not None: + self.set_cover_position(position=0) + + @property + def available(self): + """Return False if state has not been updated yet.""" + if self._ads_var is not None or self._ads_var_position is not None: + return self._state_dict[STATE_KEY_STATE] is not None or \ + self._state_dict[STATE_KEY_POSITION] is not None + return True diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 8a95f4702c7..184aee9a440 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -449,9 +449,9 @@ class _AlexaPowerController(_AlexaInterface): if name != 'powerState': raise _UnsupportedProperty(name) - if self.entity.state == STATE_ON: - return 'ON' - return 'OFF' + if self.entity.state == STATE_OFF: + return 'OFF' + return 'ON' class _AlexaLockController(_AlexaInterface): diff --git a/homeassistant/components/ambiclimate/.translations/ca.json b/homeassistant/components/ambiclimate/.translations/ca.json new file mode 100644 index 00000000000..054b1a89ae8 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "S'ha produ\u00eft un error desconegut al generat un testimoni d'acc\u00e9s.", + "already_setup": "El compte d\u2019Ambi Climate est\u00e0 configurat.", + "no_config": "Necessites configurar Ambi Climate abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Ambi Climate." + }, + "error": { + "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia", + "no_token": "No autenticat amb Ambi Climate" + }, + "step": { + "auth": { + "description": "V\u00e9s a l'[enlla\u00e7]({authorization_url}) i Permet l'acc\u00e9s al teu compte de Ambi Climate, 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" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/cs.json b/homeassistant/components/ambiclimate/.translations/cs.json new file mode 100644 index 00000000000..d34169edfc7 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "follow_link": "N\u00e1sledujte odkaz a prove\u010fte ov\u011b\u0159en\u00ed p\u0159ed stisknut\u00edm tla\u010d\u00edtka Odeslat.", + "no_token": "Nen\u00ed ov\u011b\u0159en s Ambiclimate" + }, + "step": { + "auth": { + "description": "N\u00e1sledujte tento [odkaz]({authorization_url}) a Povolit p\u0159\u00edstup k va\u0161emu \u00fa\u010dtu Ambiclimate, pot\u00e9 se vra\u0165te a stiskn\u011bte Odeslat n\u00ed\u017ee. \n (Ujist\u011bte se, \u017ee zadan\u00e1 adresa URL zp\u011btn\u00e9ho vol\u00e1n\u00ed je {cb_url} )", + "title": "Ov\u011b\u0159it Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/de.json b/homeassistant/components/ambiclimate/.translations/de.json new file mode 100644 index 00000000000..68d714cfc1b --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Unbekannter Fehler beim Generieren eines Zugriffstokens.", + "already_setup": "Das Ambiclimate Konto ist konfiguriert.", + "no_config": "Ambiclimate muss konfiguriert sein, bevor die Authentifizierund durchgef\u00fchrt werden kann. [Bitte lies die Anleitung] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Erfolgreiche Authentifizierung mit Ambiclimate" + }, + "error": { + "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst", + "no_token": "Nicht authentifiziert mit Ambiclimate" + }, + "step": { + "auth": { + "description": "Bitte folge diesem [link] ({authorization_url}) und Erlaube Zugriff auf dein Ambiclimate-Konto, komme dann zur\u00fcck und dr\u00fccke Senden darunter.\n (Pr\u00fcfe, dass die Callback-URL {cb_url} ist.)", + "title": "Ambiclimate authentifizieren" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/en.json b/homeassistant/components/ambiclimate/.translations/en.json new file mode 100644 index 00000000000..da1e173b4a8 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Unknown error generating an access token.", + "already_setup": "The Ambiclimate account is configured.", + "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Successfully authenticated with Ambiclimate" + }, + "error": { + "follow_link": "Please follow the link and authenticate before pressing Submit", + "no_token": "Not authenticated with Ambiclimate" + }, + "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})", + "title": "Authenticate Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/ko.json b/homeassistant/components/ambiclimate/.translations/ko.json new file mode 100644 index 00000000000..be337bd3f0e --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "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.", + "already_setup": "Ambi Climate \uacc4\uc815\uc774 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "no_config": "Ambi Climate \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Ambi Climate \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/ambiclimate/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "Ambi Climate \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "no_token": "Ambi Climate \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + }, + "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 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n(\ucf5c\ubc31 url \uc744 {cb_url} \ub85c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694)", + "title": "Ambi Climate \uc778\uc99d" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/lb.json b/homeassistant/components/ambiclimate/.translations/lb.json new file mode 100644 index 00000000000..a6ce441749d --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Onbekannte Feeler beim gener\u00e9ieren vum Acc\u00e8s Jeton.", + "already_setup": "Den Ambiclimate Kont ass konfigur\u00e9iert.", + "no_config": "Dir musst Ambiclimate konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/ambiclimatet/)." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Ambiclimate authentifiz\u00e9iert." + }, + "error": { + "follow_link": "Follegt w.e.g. dem Link an authentifiz\u00e9iert de Kont ier dir op ofsch\u00e9cken dr\u00e9ckt.", + "no_token": "Net mat Ambiclimate authentifiz\u00e9iert" + }, + "step": { + "auth": { + "description": "Follegt d\u00ebsem [Link]({authorization_url}) an erlaabtt den Acc\u00e8s zu \u00e4rem Ambiclimate Kont , a kommt dann zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n(Stellt s\u00e9cher dass den Type vun Callback {cb_url} ass.)", + "title": "Ambiclimate authentifiz\u00e9ieren" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/ru.json b/homeassistant/components/ambiclimate/.translations/ru.json new file mode 100644 index 00000000000..129579315a2 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\u041f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430.", + "already_setup": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Ambi Climate \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "no_config": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Ambi Climate \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\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](https://www.home-assistant.io/components/ambiclimate/)." + }, + "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." + }, + "error": { + "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\".", + "no_token": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430." + }, + "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\u0435\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})", + "title": "Ambi Climate" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/zh-Hant.json b/homeassistant/components/ambiclimate/.translations/zh-Hant.json new file mode 100644 index 00000000000..28859cbf591 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\u7522\u751f\u5b58\u53d6\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4\u3002", + "already_setup": "Ambiclimate \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_config": "\u5fc5\u9808\u5148\u8a2d\u5b9a Ambiclimate \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/ambiclimate/\uff09\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Ambiclimate \u88dd\u7f6e\u3002" + }, + "error": { + "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002", + "no_token": "Ambiclimate \u672a\u6388\u6b0a" + }, + "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", + "title": "\u8a8d\u8b49 Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py new file mode 100644 index 00000000000..07494ce6cf7 --- /dev/null +++ b/homeassistant/components/ambiclimate/__init__.py @@ -0,0 +1,44 @@ +"""Support for Ambiclimate devices.""" +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from . import config_flow +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN + + +_LOGGER = logging.getLogger(__name__) + +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, +) + + +async def async_setup(hass, config): + """Set up Ambiclimate components.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + config_flow.register_flow_implementation( + hass, conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET]) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Ambiclimate from a config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'climate')) + + return True diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py new file mode 100644 index 00000000000..d326a943761 --- /dev/null +++ b/homeassistant/components/ambiclimate/climate.py @@ -0,0 +1,230 @@ +"""Support for Ambiclimate ac.""" +import asyncio +import logging + +import ambiclimate +import voluptuous as vol + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_ON_OFF, STATE_HEAT) +from homeassistant.const import ATTR_NAME +from homeassistant.const import (ATTR_TEMPERATURE, + STATE_OFF, TEMP_CELSIUS) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import (ATTR_VALUE, CONF_CLIENT_ID, CONF_CLIENT_SECRET, + DOMAIN, SERVICE_COMFORT_FEEDBACK, SERVICE_COMFORT_MODE, + SERVICE_TEMPERATURE_MODE, STORAGE_KEY, STORAGE_VERSION) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_ON_OFF) + +SEND_COMFORT_FEEDBACK_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_VALUE): cv.string, +}) + +SET_COMFORT_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, +}) + +SET_TEMPERATURE_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_VALUE): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Ambicliamte device.""" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Ambicliamte device from config entry.""" + config = entry.data + websession = async_get_clientsession(hass) + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + token_info = await store.async_load() + + oauth = ambiclimate.AmbiclimateOAuth(config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + config['callback_url'], + websession) + + try: + _token_info = await oauth.refresh_access_token(token_info) + except ambiclimate.AmbiclimateOauthError: + _LOGGER.error("Failed to refresh access token") + return + + if _token_info: + await store.async_save(token_info) + token_info = _token_info + + data_connection = ambiclimate.AmbiclimateConnection(oauth, + token_info=token_info, + websession=websession) + + if not await data_connection.find_devices(): + _LOGGER.error("No devices found") + return + + tasks = [] + for heater in data_connection.get_devices(): + tasks.append(heater.update_device_info()) + await asyncio.wait(tasks) + + devs = [] + for heater in data_connection.get_devices(): + devs.append(AmbiclimateEntity(heater, store)) + + async_add_entities(devs, True) + + async def send_comfort_feedback(service): + """Send comfort feedback.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_comfort_feedback(service.data[ATTR_VALUE]) + + hass.services.async_register(DOMAIN, + SERVICE_COMFORT_FEEDBACK, + send_comfort_feedback, + schema=SEND_COMFORT_FEEDBACK_SCHEMA) + + async def set_comfort_mode(service): + """Set comfort mode.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_comfort_mode() + + hass.services.async_register(DOMAIN, + SERVICE_COMFORT_MODE, + set_comfort_mode, + schema=SET_COMFORT_MODE_SCHEMA) + + async def set_temperature_mode(service): + """Set temperature mode.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_temperature_mode(service.data[ATTR_VALUE]) + + hass.services.async_register(DOMAIN, + SERVICE_TEMPERATURE_MODE, + set_temperature_mode, + schema=SET_TEMPERATURE_MODE_SCHEMA) + + +class AmbiclimateEntity(ClimateDevice): + """Representation of a Ambiclimate Thermostat device.""" + + def __init__(self, heater, store): + """Initialize the thermostat.""" + self._heater = heater + self._store = store + self._data = {} + + @property + def unique_id(self): + """Return a unique ID.""" + return self._heater.device_id + + @property + def name(self): + """Return the name of the entity.""" + return self._heater.name + + @property + def device_info(self): + """Return the device info.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name, + 'manufacturer': 'Ambiclimate', + } + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._data.get('target_temperature') + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._data.get('temperature') + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._data.get('humidity') + + @property + def is_on(self): + """Return true if heater is on.""" + return self._data.get('power', '').lower() == 'on' + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._heater.get_min_temp() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._heater.get_max_temp() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def current_operation(self): + """Return current operation.""" + return STATE_HEAT if self.is_on else STATE_OFF + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._heater.set_target_temperature(temperature) + + async def async_turn_on(self): + """Turn device on.""" + await self._heater.turn_on() + + async def async_turn_off(self): + """Turn device off.""" + await self._heater.turn_off() + + async def async_update(self): + """Retrieve latest state.""" + try: + token_info = await self._heater.control.refresh_access_token() + except ambiclimate.AmbiclimateOauthError: + _LOGGER.error("Failed to refresh access token") + return + + if token_info: + await self._store.async_save(token_info) + + self._data = await self._heater.update_device() diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py new file mode 100644 index 00000000000..9bbdfceb7b0 --- /dev/null +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -0,0 +1,153 @@ +"""Config flow for Ambiclimate.""" +import logging + +import ambiclimate + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import (AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, CONF_CLIENT_ID, + CONF_CLIENT_SECRET, DOMAIN, STORAGE_VERSION, STORAGE_KEY) + +DATA_AMBICLIMATE_IMPL = 'ambiclimate_flow_implementation' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, client_id, client_secret): + """Register a ambiclimate implementation. + + client_id: Client id. + client_secret: Client secret. + """ + hass.data.setdefault(DATA_AMBICLIMATE_IMPL, {}) + + hass.data[DATA_AMBICLIMATE_IMPL] = { + CONF_CLIENT_ID: client_id, + CONF_CLIENT_SECRET: client_secret, + } + + +@config_entries.HANDLERS.register('ambiclimate') +class AmbiclimateFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self._registered_view = False + self._oauth = None + + async def async_step_user(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + config = self.hass.data.get(DATA_AMBICLIMATE_IMPL, {}) + + if not config: + _LOGGER.debug("No config") + return self.async_abort(reason='no_config') + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + errors = {} + + if user_input is not None: + errors['base'] = 'follow_link' + + if not self._registered_view: + self._generate_view() + + return self.async_show_form( + step_id='auth', + description_placeholders={'authorization_url': + await self._get_authorize_url(), + 'cb_url': self._cb_url()}, + errors=errors, + ) + + async def async_step_code(self, code=None): + """Received code for authentication.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + token_info = await self._get_token_info(code) + + if token_info is None: + return self.async_abort(reason='access_token') + + config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() + config['callback_url'] = self._cb_url() + + return self.async_create_entry( + title="Ambiclimate", + data=config, + ) + + async def _get_token_info(self, code): + oauth = self._generate_oauth() + try: + token_info = await oauth.get_access_token(code) + except ambiclimate.AmbiclimateOauthError: + _LOGGER.error("Failed to get access token", exc_info=True) + return None + + store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + await store.async_save(token_info) + + return token_info + + def _generate_view(self): + self.hass.http.register_view(AmbiclimateAuthCallbackView()) + self._registered_view = True + + def _generate_oauth(self): + config = self.hass.data[DATA_AMBICLIMATE_IMPL] + clientsession = async_get_clientsession(self.hass) + callback_url = self._cb_url() + + oauth = ambiclimate.AmbiclimateOAuth(config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + callback_url, + clientsession) + return oauth + + def _cb_url(self): + return '{}{}'.format(self.hass.config.api.base_url, + AUTH_CALLBACK_PATH) + + async def _get_authorize_url(self): + oauth = self._generate_oauth() + return oauth.get_authorize_url() + + +class AmbiclimateAuthCallbackView(HomeAssistantView): + """Ambiclimate Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + async def get(self, request): + """Receive authorization token.""" + code = request.query.get('code') + if code is None: + return "No code" + hass = request.app['hass'] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': 'code'}, + data=code, + )) + return "OK!" diff --git a/homeassistant/components/ambiclimate/const.py b/homeassistant/components/ambiclimate/const.py new file mode 100644 index 00000000000..b1b9f4c2767 --- /dev/null +++ b/homeassistant/components/ambiclimate/const.py @@ -0,0 +1,14 @@ +"""Constants used by the Ambiclimate component.""" + +ATTR_VALUE = 'value' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +DOMAIN = 'ambiclimate' +SERVICE_COMFORT_FEEDBACK = 'send_comfort_feedback' +SERVICE_COMFORT_MODE = 'set_comfort_mode' +SERVICE_TEMPERATURE_MODE = 'set_temperature_mode' +STORAGE_KEY = 'ambiclimate_auth' +STORAGE_VERSION = 1 + +AUTH_CALLBACK_NAME = 'api:ambiclimate' +AUTH_CALLBACK_PATH = '/api/ambiclimate' diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json new file mode 100644 index 00000000000..f3b3450f163 --- /dev/null +++ b/homeassistant/components/ambiclimate/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ambiclimate", + "name": "Ambiclimate", + "documentation": "https://www.home-assistant.io/components/ambiclimate", + "requirements": [ + "ambiclimate==0.1.1" + ], + "dependencies": [], + "codeowners": [ + "@danielhiversen" + ] +} diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml new file mode 100644 index 00000000000..19f47c6c35f --- /dev/null +++ b/homeassistant/components/ambiclimate/services.yaml @@ -0,0 +1,36 @@ +# Describes the format for available services for ambiclimate + +set_comfort_mode: + description: > + Enable comfort mode on your AC + fields: + Name: + description: > + String with device name. + example: Bedroom + +send_comfort_feedback: + description: > + Send feedback for comfort mode + fields: + Name: + description: > + String with device name. + example: Bedroom + Value: + description: > + Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing + example: bit_warm + +set_temperature_mode: + description: > + Enable temperature mode on your AC + fields: + Name: + description: > + String with device name. + example: Bedroom + Value: + description: > + Target value in celsius + example: 22 diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json new file mode 100644 index 00000000000..78386077af2 --- /dev/null +++ b/homeassistant/components/ambiclimate/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Ambiclimate", + "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})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Ambiclimate" + }, + "error": { + "no_token": "Not authenticated with Ambiclimate", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "The Ambiclimate account is configured.", + "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/).", + "access_token": "Unknown error generating an access token." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 3a0a983fceb..6de31caa90e 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -5,16 +5,30 @@ from datetime import timedelta import aiohttp import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_CONTROL +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.camera import DOMAIN as CAMERA +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, - CONF_BINARY_SENSORS, CONF_SENSORS, CONF_SWITCHES, CONF_SCAN_INTERVAL, - HTTP_BASIC_AUTHENTICATION) + ATTR_ENTITY_ID, CONF_AUTHENTICATION, CONF_BINARY_SENSORS, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SWITCHES, CONF_USERNAME, ENTITY_MATCH_ALL, HTTP_BASIC_AUTHENTICATION) +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service import async_extract_entity_ids + +from .binary_sensor import BINARY_SENSORS +from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST +from .const import DOMAIN, DATA_AMCREST +from .helpers import service_signal +from .sensor import SENSOR_MOTION_DETECTOR, SENSORS +from .switch import SWITCHES _LOGGER = logging.getLogger(__name__) -CONF_AUTHENTICATION = 'authentication' CONF_RESOLUTION = 'resolution' CONF_STREAM_SOURCE = 'stream_source' CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' @@ -22,12 +36,7 @@ CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 DEFAULT_RESOLUTION = 'high' -DEFAULT_STREAM_SOURCE = 'snapshot' DEFAULT_ARGUMENTS = '-pred 1' -TIMEOUT = 10 - -DATA_AMCREST = 'amcrest' -DOMAIN = 'amcrest' NOTIFICATION_ID = 'amcrest_notification' NOTIFICATION_TITLE = 'Amcrest Camera Setup' @@ -43,70 +52,60 @@ AUTHENTICATION_LIST = { 'basic': 'basic' } -STREAM_SOURCE_LIST = { - 'mjpeg': 0, - 'snapshot': 1, - 'rtsp': 2, -} -BINARY_SENSORS = { - 'motion_detected': 'Motion Detected' -} - -# Sensor types are defined like: Name, units, icon -SENSOR_MOTION_DETECTOR = 'motion_detector' -SENSORS = { - SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], -} - -# Switch types are defined like: Name, icon -SWITCHES = { - 'motion_detection': ['Motion Detection', 'mdi:run-fast'], - 'motion_recording': ['Motion Recording', 'mdi:record-rec'] -} - - -def _deprecated_sensors(value): - if SENSOR_MOTION_DETECTOR in value: +def _deprecated_sensor_values(sensors): + if SENSOR_MOTION_DETECTOR in sensors: _LOGGER.warning( - 'sensors option %s is deprecated. ' - 'Please remove from your configuration and ' - 'use binary_sensors option motion_detected instead.', - SENSOR_MOTION_DETECTOR) - return value + "The 'sensors' option value '%s' is deprecated, " + "please remove it from your configuration and use " + "the 'binary_sensors' option with value 'motion_detected' " + "instead.", SENSOR_MOTION_DETECTOR) + return sensors -def _has_unique_names(value): - names = [camera[CONF_NAME] for camera in value] +def _deprecated_switches(config): + if CONF_SWITCHES in config: + _LOGGER.warning( + "The 'switches' option (with value %s) is deprecated, " + "please remove it from your configuration and use " + "camera services and attributes instead.", + config[CONF_SWITCHES]) + return config + + +def _has_unique_names(devices): + names = [device[CONF_NAME] for device in devices] vol.Schema(vol.Unique())(names) - return value + return devices -AMCREST_SCHEMA = vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): - vol.All(vol.In(AUTHENTICATION_LIST)), - vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): - vol.All(vol.In(RESOLUTION_LIST)), - vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): - vol.All(vol.In(STREAM_SOURCE_LIST)), - vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): - cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): - cv.time_period, - vol.Optional(CONF_BINARY_SENSORS): - vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)], _deprecated_sensors), - vol.Optional(CONF_SWITCHES): - vol.All(cv.ensure_list, [vol.In(SWITCHES)]), -}) +AMCREST_SCHEMA = vol.All( + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.All(vol.In(AUTHENTICATION_LIST)), + vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): + vol.All(vol.In(RESOLUTION_LIST)), + vol.Optional(CONF_STREAM_SOURCE, default=STREAM_SOURCE_LIST[0]): + vol.All(vol.In(STREAM_SOURCE_LIST)), + vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): + cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSORS)], + _deprecated_sensor_values), + vol.Optional(CONF_SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + }), + _deprecated_switches +) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names) @@ -117,21 +116,22 @@ def setup(hass, config): """Set up the Amcrest IP Camera component.""" from amcrest import AmcrestCamera, AmcrestError - hass.data.setdefault(DATA_AMCREST, {}) - amcrest_cams = config[DOMAIN] + hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) + devices = config[DOMAIN] - for device in amcrest_cams: + for device in devices: name = device[CONF_NAME] username = device[CONF_USERNAME] password = device[CONF_PASSWORD] try: - camera = AmcrestCamera(device[CONF_HOST], - device[CONF_PORT], - username, - password).camera + api = AmcrestCamera(device[CONF_HOST], + device[CONF_PORT], + username, + password).camera # pylint: disable=pointless-statement - camera.current_time + # Test camera communications. + api.current_time except AmcrestError as ex: _LOGGER.error("Unable to connect to %s camera: %s", name, str(ex)) @@ -148,7 +148,7 @@ def setup(hass, config): binary_sensors = device.get(CONF_BINARY_SENSORS) sensors = device.get(CONF_SENSORS) switches = device.get(CONF_SWITCHES) - stream_source = STREAM_SOURCE_LIST[device[CONF_STREAM_SOURCE]] + stream_source = device[CONF_STREAM_SOURCE] # currently aiohttp only works with basic authentication # only valid for mjpeg streaming @@ -157,47 +157,97 @@ def setup(hass, config): else: authentication = None - hass.data[DATA_AMCREST][name] = AmcrestDevice( - camera, name, authentication, ffmpeg_arguments, stream_source, + hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( + api, authentication, ffmpeg_arguments, stream_source, resolution) discovery.load_platform( - hass, 'camera', DOMAIN, { + hass, CAMERA, DOMAIN, { CONF_NAME: name, }, config) if binary_sensors: discovery.load_platform( - hass, 'binary_sensor', DOMAIN, { + hass, BINARY_SENSOR, DOMAIN, { CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors }, config) if sensors: discovery.load_platform( - hass, 'sensor', DOMAIN, { + hass, SENSOR, DOMAIN, { CONF_NAME: name, CONF_SENSORS: sensors, }, config) if switches: discovery.load_platform( - hass, 'switch', DOMAIN, { + hass, SWITCH, DOMAIN, { CONF_NAME: name, CONF_SWITCHES: switches }, config) - return len(hass.data[DATA_AMCREST]) >= 1 + if not hass.data[DATA_AMCREST]['devices']: + return False + + def have_permission(user, entity_id): + return not user or user.permissions.check_entity( + entity_id, POLICY_CONTROL) + + async def async_extract_from_service(call): + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + else: + user = None + + if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: + # Return all entity_ids user has permission to control. + return [ + entity_id for entity_id in hass.data[DATA_AMCREST]['cameras'] + if have_permission(user, entity_id) + ] + + call_ids = await async_extract_entity_ids(hass, call) + entity_ids = [] + for entity_id in hass.data[DATA_AMCREST]['cameras']: + if entity_id not in call_ids: + continue + if not have_permission(user, entity_id): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL + ) + entity_ids.append(entity_id) + return entity_ids + + async def async_service_handler(call): + args = [] + for arg in CAMERA_SERVICES[call.service][2]: + args.append(call.data[arg]) + for entity_id in await async_extract_from_service(call): + async_dispatcher_send( + hass, + service_signal(call.service, entity_id), + *args + ) + + for service, params in CAMERA_SERVICES.items(): + hass.services.async_register( + DOMAIN, service, async_service_handler, params[0]) + + return True class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__(self, camera, name, authentication, ffmpeg_arguments, + def __init__(self, api, authentication, ffmpeg_arguments, stream_source, resolution): """Initialize the entity.""" - self.device = camera - self.name = name + self.api = api self.authentication = authentication self.ffmpeg_arguments = ffmpeg_arguments self.stream_source = stream_source diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index b591616a88d..0eb9e42e707 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -5,38 +5,39 @@ import logging from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASS_MOTION) from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS -from . import DATA_AMCREST, BINARY_SENSORS + +from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) + +BINARY_SENSORS = { + 'motion_detected': 'Motion Detected' +} -async def async_setup_platform(hass, config, async_add_devices, +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a binary sensor for an Amcrest IP Camera.""" if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - binary_sensors = discovery_info[CONF_BINARY_SENSORS] - amcrest = hass.data[DATA_AMCREST][device_name] - - amcrest_binary_sensors = [] - for sensor_type in binary_sensors: - amcrest_binary_sensors.append( - AmcrestBinarySensor(amcrest.name, amcrest.device, sensor_type)) - - async_add_devices(amcrest_binary_sensors, True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestBinarySensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_BINARY_SENSORS]], + True) class AmcrestBinarySensor(BinarySensorDevice): """Binary sensor for Amcrest camera.""" - def __init__(self, name, camera, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize entity.""" self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type]) - self._camera = camera + self._api = device.api self._sensor_type = sensor_type self._state = None @@ -62,7 +63,7 @@ class AmcrestBinarySensor(BinarySensorDevice): _LOGGER.debug('Pulling data from %s binary sensor', self._name) try: - self._state = self._camera.is_motion_detected + self._state = self._api.is_motion_detected except AmcrestError as error: _LOGGER.error( 'Could not update %s binary sensor due to error: %s', diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 07f5d403ba8..e646c11f2e9 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -2,18 +2,72 @@ import asyncio import logging +import voluptuous as vol + from homeassistant.components.camera import ( - Camera, SUPPORT_ON_OFF, SUPPORT_STREAM) + Camera, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, SUPPORT_STREAM) from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.const import CONF_NAME +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF) from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, async_get_clientsession) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT +from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST +from .helpers import service_signal _LOGGER = logging.getLogger(__name__) +STREAM_SOURCE_LIST = [ + 'snapshot', + 'mjpeg', + 'rtsp', +] + +_SRV_EN_REC = 'enable_recording' +_SRV_DS_REC = 'disable_recording' +_SRV_EN_AUD = 'enable_audio' +_SRV_DS_AUD = 'disable_audio' +_SRV_EN_MOT_REC = 'enable_motion_recording' +_SRV_DS_MOT_REC = 'disable_motion_recording' +_SRV_GOTO = 'goto_preset' +_SRV_CBW = 'set_color_bw' +_SRV_TOUR_ON = 'start_tour' +_SRV_TOUR_OFF = 'stop_tour' + +_ATTR_PRESET = 'preset' +_ATTR_COLOR_BW = 'color_bw' + +_CBW_COLOR = 'color' +_CBW_AUTO = 'auto' +_CBW_BW = 'bw' +_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW] + +_SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1)), +}) +_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(_ATTR_COLOR_BW): vol.In(_CBW), +}) + +CAMERA_SERVICES = { + _SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, 'async_enable_recording', ()), + _SRV_DS_REC: (CAMERA_SERVICE_SCHEMA, 'async_disable_recording', ()), + _SRV_EN_AUD: (CAMERA_SERVICE_SCHEMA, 'async_enable_audio', ()), + _SRV_DS_AUD: (CAMERA_SERVICE_SCHEMA, 'async_disable_audio', ()), + _SRV_EN_MOT_REC: ( + CAMERA_SERVICE_SCHEMA, 'async_enable_motion_recording', ()), + _SRV_DS_MOT_REC: ( + CAMERA_SERVICE_SCHEMA, 'async_disable_motion_recording', ()), + _SRV_GOTO: (_SRV_GOTO_SCHEMA, 'async_goto_preset', (_ATTR_PRESET,)), + _SRV_CBW: (_SRV_CBW_SCHEMA, 'async_set_color_bw', (_ATTR_COLOR_BW,)), + _SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, 'async_start_tour', ()), + _SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, 'async_stop_tour', ()), +} + +_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -21,28 +75,33 @@ async def async_setup_platform(hass, config, async_add_entities, if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - amcrest = hass.data[DATA_AMCREST][device_name] - - async_add_entities([AmcrestCam(hass, amcrest)], True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities([ + AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, hass, amcrest): + def __init__(self, name, device, ffmpeg): """Initialize an Amcrest camera.""" - super(AmcrestCam, self).__init__() - self._name = amcrest.name - self._camera = amcrest.device - self._ffmpeg = hass.data[DATA_FFMPEG] - self._ffmpeg_arguments = amcrest.ffmpeg_arguments - self._stream_source = amcrest.stream_source - self._resolution = amcrest.resolution - self._token = self._auth = amcrest.authentication + super().__init__() + self._name = name + self._api = device.api + self._ffmpeg = ffmpeg + self._ffmpeg_arguments = device.ffmpeg_arguments + self._stream_source = device.stream_source + self._resolution = device.resolution + self._token = self._auth = device.authentication self._is_recording = False + self._motion_detection_enabled = None self._model = None + self._audio_enabled = None + self._motion_recording_enabled = None + self._color_bw = None self._snapshot_lock = asyncio.Lock() + self._unsub_dispatcher = [] async def async_camera_image(self): """Return a still image response from the camera.""" @@ -56,7 +115,7 @@ class AmcrestCam(Camera): try: # Send the request to snap a picture and return raw jpg data response = await self.hass.async_add_executor_job( - self._camera.snapshot, self._resolution) + self._api.snapshot, self._resolution) return response.data except AmcrestError as error: _LOGGER.error( @@ -67,15 +126,16 @@ class AmcrestCam(Camera): async def handle_async_mjpeg_stream(self, request): """Return an MJPEG stream.""" # The snapshot implementation is handled by the parent class - if self._stream_source == STREAM_SOURCE_LIST['snapshot']: + if self._stream_source == 'snapshot': return await super().handle_async_mjpeg_stream(request) - if self._stream_source == STREAM_SOURCE_LIST['mjpeg']: + if self._stream_source == 'mjpeg': # stream an MJPEG image stream directly from the camera websession = async_get_clientsession(self.hass) - streaming_url = self._camera.mjpeg_url(typeno=self._resolution) + streaming_url = self._api.mjpeg_url(typeno=self._resolution) stream_coro = websession.get( - streaming_url, auth=self._token, timeout=TIMEOUT) + streaming_url, auth=self._token, + timeout=CAMERA_WEB_SESSION_TIMEOUT) return await async_aiohttp_proxy_web( self.hass, request, stream_coro) @@ -83,7 +143,7 @@ class AmcrestCam(Camera): # streaming via ffmpeg from haffmpeg.camera import CameraMjpeg - streaming_url = self._camera.rtsp_url(typeno=self._resolution) + streaming_url = self._api.rtsp_url(typeno=self._resolution) stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) await stream.open_camera( streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -103,6 +163,19 @@ class AmcrestCam(Camera): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the Amcrest-specific camera state attributes.""" + attr = {} + if self._audio_enabled is not None: + attr['audio'] = _BOOL_TO_STATE.get(self._audio_enabled) + if self._motion_recording_enabled is not None: + attr['motion_recording'] = _BOOL_TO_STATE.get( + self._motion_recording_enabled) + if self._color_bw is not None: + attr[_ATTR_COLOR_BW] = self._color_bw + return attr + @property def supported_features(self): """Return supported features.""" @@ -120,6 +193,11 @@ class AmcrestCam(Camera): """Return the camera brand.""" return 'Amcrest' + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_detection_enabled + @property def model(self): """Return the camera model.""" @@ -128,7 +206,7 @@ class AmcrestCam(Camera): @property def stream_source(self): """Return the source of the stream.""" - return self._camera.rtsp_url(typeno=self._resolution) + return self._api.rtsp_url(typeno=self._resolution) @property def is_on(self): @@ -137,6 +215,21 @@ class AmcrestCam(Camera): # Other Entity method overrides + async def async_added_to_hass(self): + """Subscribe to signals and add camera to list.""" + for service, params in CAMERA_SERVICES.items(): + self._unsub_dispatcher.append(async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, params[1]))) + self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id) + + async def async_will_remove_from_hass(self): + """Remove camera from list and disconnect from signals.""" + self.hass.data[DATA_AMCREST]['cameras'].remove(self.entity_id) + for unsub_dispatcher in self._unsub_dispatcher: + unsub_dispatcher() + def update(self): """Update entity status.""" from amcrest import AmcrestError @@ -144,15 +237,21 @@ class AmcrestCam(Camera): _LOGGER.debug('Pulling data from %s camera', self.name) if self._model is None: try: - self._model = self._camera.device_type.split('=')[-1].strip() + self._model = self._api.device_type.split('=')[-1].strip() except AmcrestError as error: _LOGGER.error( 'Could not get %s camera model due to error: %s', self.name, error) self._model = '' try: - self.is_streaming = self._camera.video_enabled - self._is_recording = self._camera.record_mode == 'Manual' + self.is_streaming = self._api.video_enabled + self._is_recording = self._api.record_mode == 'Manual' + self._motion_detection_enabled = ( + self._api.is_motion_detector_on()) + self._audio_enabled = self._api.audio_enabled + self._motion_recording_enabled = ( + self._api.is_record_on_motion_detection()) + self._color_bw = _CBW[self._api.day_night_color] except AmcrestError as error: _LOGGER.error( 'Could not get %s camera attributes due to error: %s', @@ -168,14 +267,71 @@ class AmcrestCam(Camera): """Turn on camera.""" self._enable_video_stream(True) - # Utility methods + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._enable_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._enable_motion_detection(False) + + # Additional Amcrest Camera service methods + + async def async_enable_recording(self): + """Call the job and enable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, True) + + async def async_disable_recording(self): + """Call the job and disable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, False) + + async def async_enable_audio(self): + """Call the job and enable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, True) + + async def async_disable_audio(self): + """Call the job and disable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, False) + + async def async_enable_motion_recording(self): + """Call the job and enable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, + True) + + async def async_disable_motion_recording(self): + """Call the job and disable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, + False) + + async def async_goto_preset(self, preset): + """Call the job and move camera to preset position.""" + await self.hass.async_add_executor_job(self._goto_preset, preset) + + async def async_set_color_bw(self, color_bw): + """Call the job and set camera color mode.""" + await self.hass.async_add_executor_job(self._set_color_bw, color_bw) + + async def async_start_tour(self): + """Call the job and start camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, True) + + async def async_stop_tour(self): + """Call the job and stop camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, False) + + # Methods to send commands to Amcrest camera and handle errors def _enable_video_stream(self, enable): """Enable or disable camera video stream.""" from amcrest import AmcrestError + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # recording on if video stream is being turned off. + if self.is_recording and not enable: + self._enable_recording(False) try: - self._camera.video_enabled = enable + self._api.video_enabled = enable except AmcrestError as error: _LOGGER.error( 'Could not %s %s camera video stream due to error: %s', @@ -183,3 +339,103 @@ class AmcrestCam(Camera): else: self.is_streaming = enable self.schedule_update_ha_state() + + def _enable_recording(self, enable): + """Turn recording on or off.""" + from amcrest import AmcrestError + + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # video stream off if recording is being turned on. + if not self.is_streaming and enable: + self._enable_video_stream(True) + rec_mode = {'Automatic': 0, 'Manual': 1} + try: + self._api.record_mode = rec_mode[ + 'Manual' if enable else 'Automatic'] + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera recording due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._is_recording = enable + self.schedule_update_ha_state() + + def _enable_motion_detection(self, enable): + """Enable or disable motion detection.""" + from amcrest import AmcrestError + + try: + self._api.motion_detection = str(enable).lower() + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera motion detection due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._motion_detection_enabled = enable + self.schedule_update_ha_state() + + def _enable_audio(self, enable): + """Enable or disable audio stream.""" + from amcrest import AmcrestError + + try: + self._api.audio_enabled = enable + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera audio stream due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._audio_enabled = enable + self.schedule_update_ha_state() + + def _enable_motion_recording(self, enable): + """Enable or disable motion recording.""" + from amcrest import AmcrestError + + try: + self._api.motion_recording = str(enable).lower() + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera motion recording due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._motion_recording_enabled = enable + self.schedule_update_ha_state() + + def _goto_preset(self, preset): + """Move camera position and zoom to preset.""" + from amcrest import AmcrestError + + try: + self._api.go_to_preset( + action='start', preset_point_number=preset) + except AmcrestError as error: + _LOGGER.error( + 'Could not move %s camera to preset %i due to error: %s', + self.name, preset, error) + + def _set_color_bw(self, cbw): + """Set camera color mode.""" + from amcrest import AmcrestError + + try: + self._api.day_night_color = _CBW.index(cbw) + except AmcrestError as error: + _LOGGER.error( + 'Could not set %s camera color mode to %s due to error: %s', + self.name, cbw, error) + else: + self._color_bw = cbw + self.schedule_update_ha_state() + + def _start_tour(self, start): + """Start camera tour.""" + from amcrest import AmcrestError + + try: + self._api.tour(start=start) + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera tour due to error: %s', + 'start' if start else 'stop', self.name, error) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py new file mode 100644 index 00000000000..a0230937e95 --- /dev/null +++ b/homeassistant/components/amcrest/const.py @@ -0,0 +1,7 @@ +"""Constants for amcrest component.""" +DOMAIN = 'amcrest' +DATA_AMCREST = DOMAIN + +BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 +CAMERA_WEB_SESSION_TIMEOUT = 10 +SENSOR_SCAN_INTERVAL_SECS = 10 diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py new file mode 100644 index 00000000000..270c969a6cc --- /dev/null +++ b/homeassistant/components/amcrest/helpers.py @@ -0,0 +1,10 @@ +"""Helpers for amcrest component.""" +from .const import DOMAIN + + +def service_signal(service, entity_id=None): + """Encode service and entity_id into signal.""" + signal = '{}_{}'.format(DOMAIN, service) + if entity_id: + signal += '_{}'.format(entity_id.replace('.', '_')) + return signal diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index e05fdcf4bd4..a2eb8c24e21 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -3,7 +3,7 @@ "name": "Amcrest", "documentation": "https://www.home-assistant.io/components/amcrest", "requirements": [ - "amcrest==1.3.0" + "amcrest==1.4.0" ], "dependencies": [ "ffmpeg" diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 56cb021052e..718d08358c4 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -5,11 +5,19 @@ import logging from homeassistant.const import CONF_NAME, CONF_SENSORS from homeassistant.helpers.entity import Entity -from . import DATA_AMCREST, SENSORS +from .const import DATA_AMCREST, SENSOR_SCAN_INTERVAL_SECS _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) + +# Sensor types are defined like: Name, units, icon +SENSOR_MOTION_DETECTOR = 'motion_detector' +SENSORS = { + SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], + 'sdcard': ['SD Used', '%', 'mdi:sd'], + 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], +} async def async_setup_platform( @@ -18,30 +26,26 @@ async def async_setup_platform( if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - sensors = discovery_info[CONF_SENSORS] - amcrest = hass.data[DATA_AMCREST][device_name] - - amcrest_sensors = [] - for sensor_type in sensors: - amcrest_sensors.append( - AmcrestSensor(amcrest.name, amcrest.device, sensor_type)) - - async_add_entities(amcrest_sensors, True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestSensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_SENSORS]], + True) class AmcrestSensor(Entity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, camera, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" - self._attrs = {} - self._camera = camera + self._name = '{} {}'.format(name, SENSORS[sensor_type][0]) + self._api = device.api self._sensor_type = sensor_type - self._name = '{0}_{1}'.format( - name, SENSORS.get(self._sensor_type)[0]) - self._icon = 'mdi:{}'.format(SENSORS.get(self._sensor_type)[2]) self._state = None + self._attrs = {} + self._unit_of_measurement = SENSORS[sensor_type][1] + self._icon = SENSORS[sensor_type][2] @property def name(self): @@ -66,22 +70,30 @@ class AmcrestSensor(Entity): @property def unit_of_measurement(self): """Return the units of measurement.""" - return SENSORS.get(self._sensor_type)[1] + return self._unit_of_measurement def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Pulling data from %s sensor.", self._name) if self._sensor_type == 'motion_detector': - self._state = self._camera.is_motion_detected - self._attrs['Record Mode'] = self._camera.record_mode + self._state = self._api.is_motion_detected + self._attrs['Record Mode'] = self._api.record_mode elif self._sensor_type == 'ptz_preset': - self._state = self._camera.ptz_presets_count + self._state = self._api.ptz_presets_count elif self._sensor_type == 'sdcard': - sd_used = self._camera.storage_used - sd_total = self._camera.storage_total - self._attrs['Total'] = '{0} {1}'.format(*sd_total) - self._attrs['Used'] = '{0} {1}'.format(*sd_used) - self._state = self._camera.storage_used_percent + storage = self._api.storage_all + try: + self._attrs['Total'] = '{:.2f} {}'.format(*storage['total']) + except ValueError: + self._attrs['Total'] = '{} {}'.format(*storage['total']) + try: + self._attrs['Used'] = '{:.2f} {}'.format(*storage['used']) + except ValueError: + self._attrs['Used'] = '{} {}'.format(*storage['used']) + try: + self._state = '{:.2f}'.format(storage['used_percent']) + except ValueError: + self._state = storage['used_percent'] diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml new file mode 100644 index 00000000000..d6e7a02a4f9 --- /dev/null +++ b/homeassistant/components/amcrest/services.yaml @@ -0,0 +1,75 @@ +enable_recording: + description: Enable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_recording: + description: Disable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_audio: + description: Enable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_audio: + description: Disable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_motion_recording: + description: Enable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_motion_recording: + description: Disable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +goto_preset: + description: Move camera to PTZ preset. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + preset: + description: Preset number, starting from 1. + example: 1 + +set_color_bw: + description: Set camera color mode. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + color_bw: + description: Color mode, one of 'auto', 'color' or 'bw'. + example: auto + +start_tour: + description: Start camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +stop_tour: + description: Stop camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py index 90f750d1797..5989d4daf1e 100644 --- a/homeassistant/components/amcrest/switch.py +++ b/homeassistant/components/amcrest/switch.py @@ -1,13 +1,19 @@ """Support for toggling Amcrest IP camera settings.""" import logging -from homeassistant.const import CONF_NAME, CONF_SWITCHES, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.helpers.entity import ToggleEntity -from . import DATA_AMCREST, SWITCHES +from .const import DATA_AMCREST _LOGGER = logging.getLogger(__name__) +# Switch types are defined like: Name, icon +SWITCHES = { + 'motion_detection': ['Motion Detection', 'mdi:run-fast'], + 'motion_recording': ['Motion Recording', 'mdi:record-rec'] +} + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -16,67 +22,58 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - switches = discovery_info[CONF_SWITCHES] - camera = hass.data[DATA_AMCREST][name].device - - all_switches = [] - - for setting in switches: - all_switches.append(AmcrestSwitch(setting, camera, name)) - - async_add_entities(all_switches, True) + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestSwitch(name, device, setting) + for setting in discovery_info[CONF_SWITCHES]], + True) class AmcrestSwitch(ToggleEntity): """Representation of an Amcrest IP camera switch.""" - def __init__(self, setting, camera, name): + def __init__(self, name, device, setting): """Initialize the Amcrest switch.""" + self._name = '{} {}'.format(name, SWITCHES[setting][0]) + self._api = device.api self._setting = setting - self._camera = camera - self._name = '{} {}'.format(SWITCHES[setting][0], name) + self._state = False self._icon = SWITCHES[setting][1] - self._state = None @property def name(self): """Return the name of the switch if any.""" return self._name - @property - def state(self): - """Return the state of the switch.""" - return self._state - @property def is_on(self): """Return true if switch is on.""" - return self._state == STATE_ON + return self._state def turn_on(self, **kwargs): """Turn setting on.""" if self._setting == 'motion_detection': - self._camera.motion_detection = 'true' + self._api.motion_detection = 'true' elif self._setting == 'motion_recording': - self._camera.motion_recording = 'true' + self._api.motion_recording = 'true' def turn_off(self, **kwargs): """Turn setting off.""" if self._setting == 'motion_detection': - self._camera.motion_detection = 'false' + self._api.motion_detection = 'false' elif self._setting == 'motion_recording': - self._camera.motion_recording = 'false' + self._api.motion_recording = 'false' def update(self): """Update setting state.""" _LOGGER.debug("Polling state for setting: %s ", self._name) if self._setting == 'motion_detection': - detection = self._camera.is_motion_detector_on() + detection = self._api.is_motion_detector_on() elif self._setting == 'motion_recording': - detection = self._camera.is_record_on_motion_detection() + detection = self._api.is_record_on_motion_detection() - self._state = STATE_ON if detection else STATE_OFF + self._state = detection @property def icon(self): diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json index bfec5cd9274..dd1d6f54377 100644 --- a/homeassistant/components/auth/.translations/es.json +++ b/homeassistant/components/auth/.translations/es.json @@ -21,11 +21,11 @@ }, "totp": { "error": { - "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, por favor aseg\u00farate de que el reloj de tu Home Assistant es correcto." + "invalid_code": "C\u00f3digo inv\u00e1lido, int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, aseg\u00farate de que el reloj de tu sistema Home Assistant es correcto." }, "step": { "init": { - "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.", + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos el [Autenticador de Google](https://support.google.com/accounts/answer/1066447) o [Authy](https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo **`{code}`**.", "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP" } }, diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index c2f03341d20..7fd767f4a43 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -97,8 +97,7 @@ class AuthProvidersView(HomeAssistantView): async def get(self, request): """Get available auth providers.""" hass = request.app['hass'] - - if not hass.components.onboarding.async_is_onboarded(): + if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( message='Onboarding not finished', status_code=400, diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index ed86d52584f..6371be28021 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -31,6 +31,6 @@ async def async_trigger(hass, config, action, automation_info): 'from_state': from_s, 'to_state': to_s, }, - }, context=to_s.context)) + }, context=(to_s.context if to_s else None))) return async_track_template(hass, value_template, template_listener) diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index c6cd6976129..5e730708591 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -1,7 +1,7 @@ """Constants for the Axis component.""" import logging -LOGGER = logging.getLogger('homeassistant.components.axis') +LOGGER = logging.getLogger(__package__) DOMAIN = 'axis' diff --git a/homeassistant/components/bizkaibus/__init__.py b/homeassistant/components/bizkaibus/__init__.py new file mode 100644 index 00000000000..e37c17e5744 --- /dev/null +++ b/homeassistant/components/bizkaibus/__init__.py @@ -0,0 +1 @@ +"""The Bizkaibus bus tracker component.""" diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json new file mode 100644 index 00000000000..98cbbc9be56 --- /dev/null +++ b/homeassistant/components/bizkaibus/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bizkaibus", + "name": "Bizkaibus", + "documentation": "https://www.home-assistant.io/components/bizkaibus", + "dependencies": [], + "codeowners": ["@UgaitzEtxebarria"], + "requirements": ["bizkaibus==0.1.1"] +} diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py new file mode 100755 index 00000000000..96e6ee5d56f --- /dev/null +++ b/homeassistant/components/bizkaibus/sensor.py @@ -0,0 +1,88 @@ +"""Support for Bizkaibus, Biscay (Basque Country, Spain) Bus service.""" + +import logging + +import voluptuous as vol +from bizkaibus.bizkaibus import BizkaibusData +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + + +_LOGGER = logging.getLogger(__name__) + +ATTR_DUE_IN = 'Due in' + +CONF_STOP_ID = 'stopid' +CONF_ROUTE = 'route' + +DEFAULT_NAME = 'Next bus' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_ID): cv.string, + vol.Required(CONF_ROUTE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Bizkaibus public transport sensor.""" + name = config.get(CONF_NAME) + stop = config[CONF_STOP_ID] + route = config[CONF_ROUTE] + + data = Bizkaibus(stop, route) + add_entities([BizkaibusSensor(data, stop, route, name)], True) + + +class BizkaibusSensor(Entity): + """The class for handling the data.""" + + def __init__(self, data, stop, route, name): + """Initialize the sensor.""" + self.data = data + self.stop = stop + self.route = route + self._name = name + self._state = None + + @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 unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return 'minutes' + + def update(self): + """Get the latest data from the webservice.""" + self.data.update() + try: + self._state = self.data.info[0][ATTR_DUE_IN] + except TypeError: + pass + + +class Bizkaibus: + """The class for handling the data retrieval.""" + + def __init__(self, stop, route): + """Initialize the data object.""" + self.stop = stop + self.route = route + self.info = None + + def update(self): + """Retrieve the information from API.""" + bridge = BizkaibusData(self.stop, self.route) + bridge.getNextBus() + self.info = bridge.info diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 9016502b5d3..7731f845005 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -3,7 +3,7 @@ "name": "Bluesound", "documentation": "https://www.home-assistant.io/components/bluesound", "requirements": [ - "xmltodict==0.11.0" + "xmltodict==0.12.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json index cb7ce4383b0..eb1f1d8ca94 100644 --- a/homeassistant/components/bom/manifest.json +++ b/homeassistant/components/bom/manifest.json @@ -3,7 +3,7 @@ "name": "Bom", "documentation": "https://www.home-assistant.io/components/bom", "requirements": [ - "bomradarloop==0.1.2" + "bomradarloop==0.1.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 446473c7f40..594a6473877 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.calendar import ( PLATFORM_SCHEMA, CalendarEventDevice, get_date) from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt @@ -36,7 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_SEARCH): cv.string, }) - ])) + ])), + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean }) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -50,7 +51,8 @@ def setup_platform(hass, config, add_entities, disc_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - client = caldav.DAVClient(url, None, username, password) + client = caldav.DAVClient(url, None, username, password, + ssl_verify_cert=config.get(CONF_VERIFY_SSL)) calendars = client.principal().calendars() diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 35fe29809f8..ee10f06c985 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send) from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util +from homeassistant.util.logging import async_create_catching_coro from . import DOMAIN as CAST_DOMAIN @@ -522,8 +523,8 @@ class CastDevice(MediaPlayerDevice): if _is_matching_dynamic_group(self._cast_info, discover): _LOGGER.debug("Discovered matching dynamic group: %s", discover) - self.hass.async_create_task( - self.async_set_dynamic_group(discover)) + self.hass.async_create_task(async_create_catching_coro( + self.async_set_dynamic_group(discover))) return if self._cast_info.uuid != discover.uuid: @@ -536,7 +537,8 @@ class CastDevice(MediaPlayerDevice): self._cast_info.host, self._cast_info.port) return _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) - self.hass.async_create_task(self.async_set_cast_info(discover)) + self.hass.async_create_task(async_create_catching_coro( + self.async_set_cast_info(discover))) def async_cast_removed(discover: ChromecastInfo): """Handle removal of Chromecast.""" @@ -546,13 +548,15 @@ class CastDevice(MediaPlayerDevice): if (self._dynamic_group_cast_info is not None and self._dynamic_group_cast_info.uuid == discover.uuid): _LOGGER.debug("Removed matching dynamic group: %s", discover) - self.hass.async_create_task(self.async_del_dynamic_group()) + self.hass.async_create_task(async_create_catching_coro( + self.async_del_dynamic_group())) return if self._cast_info.uuid != discover.uuid: # Removed is not our device. return _LOGGER.debug("Removed chromecast with same UUID: %s", discover) - self.hass.async_create_task(self.async_del_cast_info(discover)) + self.hass.async_create_task(async_create_catching_coro( + self.async_del_cast_info(discover))) async def async_stop(event): """Disconnect socket on Home Assistant stop.""" @@ -565,14 +569,15 @@ class CastDevice(MediaPlayerDevice): self.hass, SIGNAL_CAST_REMOVED, async_cast_removed) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - self.hass.async_create_task(self.async_set_cast_info(self._cast_info)) + self.hass.async_create_task(async_create_catching_coro( + self.async_set_cast_info(self._cast_info))) for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]: if _is_matching_dynamic_group(self._cast_info, info): _LOGGER.debug("[%s %s (%s:%s)] Found dynamic group: %s", self.entity_id, self._cast_info.friendly_name, self._cast_info.host, self._cast_info.port, info) - self.hass.async_create_task( - self.async_set_dynamic_group(info)) + self.hass.async_create_task(async_create_catching_coro( + self.async_set_dynamic_group(info))) break async def async_will_remove_from_hass(self) -> None: @@ -1046,6 +1051,11 @@ class CastDevice(MediaPlayerDevice): return images[0].url if images and images[0].url else None + @property + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return True + @property def media_title(self): """Title of current playing media.""" diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index ab7ada618fe..53aa21c91c6 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -3,7 +3,9 @@ import logging import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME,\ + CONF_MAXIMUM, CONF_MINIMUM + import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity @@ -12,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = 'initial' ATTR_STEP = 'step' +ATTR_MINIMUM = 'minimum' +ATTR_MAXIMUM = 'maximum' CONF_INITIAL = 'initial' CONF_RESTORE = 'restore' @@ -26,11 +30,19 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' SERVICE_DECREMENT = 'decrement' SERVICE_INCREMENT = 'increment' SERVICE_RESET = 'reset' +SERVICE_CONFIGURE = 'configure' -SERVICE_SCHEMA = vol.Schema({ +SERVICE_SCHEMA_SIMPLE = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, }) +SERVICE_SCHEMA_CONFIGURE = vol.Schema({ + ATTR_ENTITY_ID: cv.comp_entity_ids, + vol.Optional(ATTR_MINIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_MAXIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_STEP): cv.positive_int, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: cv.schema_with_slug_keys( vol.Any({ @@ -38,6 +50,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): cv.positive_int, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAXIMUM, default=None): + vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_MINIMUM, default=None): + vol.Any(None, vol.Coerce(int)), vol.Optional(CONF_RESTORE, default=True): cv.boolean, vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, }, None) @@ -60,21 +76,27 @@ async def async_setup(hass, config): restore = cfg.get(CONF_RESTORE) step = cfg.get(CONF_STEP) icon = cfg.get(CONF_ICON) + minimum = cfg.get(CONF_MINIMUM) + maximum = cfg.get(CONF_MAXIMUM) - entities.append(Counter(object_id, name, initial, restore, step, icon)) + entities.append(Counter(object_id, name, initial, minimum, maximum, + restore, step, icon)) if not entities: return False component.async_register_entity_service( - SERVICE_INCREMENT, SERVICE_SCHEMA, + SERVICE_INCREMENT, SERVICE_SCHEMA_SIMPLE, 'async_increment') component.async_register_entity_service( - SERVICE_DECREMENT, SERVICE_SCHEMA, + SERVICE_DECREMENT, SERVICE_SCHEMA_SIMPLE, 'async_decrement') component.async_register_entity_service( - SERVICE_RESET, SERVICE_SCHEMA, + SERVICE_RESET, SERVICE_SCHEMA_SIMPLE, 'async_reset') + component.async_register_entity_service( + SERVICE_CONFIGURE, SERVICE_SCHEMA_CONFIGURE, + 'async_configure') await component.async_add_entities(entities) return True @@ -83,13 +105,16 @@ async def async_setup(hass, config): class Counter(RestoreEntity): """Representation of a counter.""" - def __init__(self, object_id, name, initial, restore, step, icon): + def __init__(self, object_id, name, initial, minimum, maximum, + restore, step, icon): """Initialize a counter.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._restore = restore self._step = step self._state = self._initial = initial + self._min = minimum + self._max = maximum self._icon = icon @property @@ -115,10 +140,24 @@ class Counter(RestoreEntity): @property def state_attributes(self): """Return the state attributes.""" - return { + ret = { ATTR_INITIAL: self._initial, ATTR_STEP: self._step, } + if self._min is not None: + ret[CONF_MINIMUM] = self._min + if self._max is not None: + ret[CONF_MAXIMUM] = self._max + return ret + + def compute_next_state(self, state): + """Keep the state within the range of min/max values.""" + if self._min is not None: + state = max(self._min, state) + if self._max is not None: + state = min(self._max, state) + + return state async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" @@ -128,19 +167,31 @@ class Counter(RestoreEntity): if self._restore: state = await self.async_get_last_state() if state is not None: - self._state = int(state.state) + self._state = self.compute_next_state(int(state.state)) async def async_decrement(self): """Decrement the counter.""" - self._state -= self._step + self._state = self.compute_next_state(self._state - self._step) await self.async_update_ha_state() async def async_increment(self): """Increment a counter.""" - self._state += self._step + self._state = self.compute_next_state(self._state + self._step) await self.async_update_ha_state() async def async_reset(self): """Reset a counter.""" - self._state = self._initial + self._state = self.compute_next_state(self._initial) + await self.async_update_ha_state() + + async def async_configure(self, **kwargs): + """Change the counter's settings with a service.""" + if CONF_MINIMUM in kwargs: + self._min = kwargs[CONF_MINIMUM] + if CONF_MAXIMUM in kwargs: + self._max = kwargs[CONF_MAXIMUM] + if CONF_STEP in kwargs: + self._step = kwargs[CONF_STEP] + + self._state = self.compute_next_state(self._state) await self.async_update_ha_state() diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index ef76f9b9eac..fc3f0ad36cb 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -17,4 +17,19 @@ reset: fields: entity_id: description: Entity id of the counter to reset. - example: 'counter.count0' \ No newline at end of file + example: 'counter.count0' +configure: + description: Change counter parameters + 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 + example: 0 + maximum: + description: New maximum value for the counter or None to remove maximum + example: 100 + step: + description: New value for step + example: 2 diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index f348c88daac..7ea4e117743 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -6,9 +6,10 @@ import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice from homeassistant.components.climate.const import ( - ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_OPERATION_MODE, - ATTR_SWING_MODE, STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, - STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, + ATTR_AWAY_MODE, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, + ATTR_OPERATION_MODE, ATTR_SWING_MODE, STATE_AUTO, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, + SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, STATE_OFF, TEMP_CELSIUS) @@ -44,6 +45,7 @@ DAIKIN_TO_HA_STATE = { } HA_ATTR_TO_DAIKIN = { + ATTR_AWAY_MODE: 'en_hol', ATTR_OPERATION_MODE: 'mode', ATTR_FAN_MODE: 'f_rate', ATTR_SWING_MODE: 'f_dir', @@ -93,8 +95,9 @@ class DaikinClimate(ClimateDevice): ), } - self._supported_features = SUPPORT_TARGET_TEMPERATURE \ - | SUPPORT_OPERATION_MODE + self._supported_features = (SUPPORT_AWAY_MODE | SUPPORT_ON_OFF + | SUPPORT_OPERATION_MODE + | SUPPORT_TARGET_TEMPERATURE) if self._api.device.support_fan_mode: self._supported_features |= SUPPORT_FAN_MODE @@ -266,3 +269,36 @@ class DaikinClimate(ClimateDevice): def device_info(self): """Return a device description for device registry.""" return self._api.device_info + + @property + def is_on(self): + """Return true if on.""" + return self._api.device.represent( + HA_ATTR_TO_DAIKIN[ATTR_OPERATION_MODE] + )[1] != HA_STATE_TO_DAIKIN[STATE_OFF] + + async def async_turn_on(self): + """Turn device on.""" + await self._api.device.set({}) + + async def async_turn_off(self): + """Turn device off.""" + await self._api.device.set({ + HA_ATTR_TO_DAIKIN[ATTR_OPERATION_MODE]: + HA_STATE_TO_DAIKIN[STATE_OFF] + }) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._api.device.represent( + HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE] + )[1] != HA_STATE_TO_DAIKIN[STATE_OFF] + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + await self._api.device.set({HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE]: '1'}) + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self._api.device.set({HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE]: '0'}) diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index a340b94e9a4..6e86b16c02d 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -85,5 +85,9 @@ class DanfossAir: = self._client.command(ReadCommand.boost) self._data[ReadCommand.battery_percent] \ = self._client.command(ReadCommand.battery_percent) + self._data[ReadCommand.bypass] \ + = self._client.command(ReadCommand.bypass) + self._data[ReadCommand.automatic_bypass] \ + = self._client.command(ReadCommand.automatic_bypass) _LOGGER.debug("Done fetching data from Danfoss Air CCM module") diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json index 8af1707de65..a210b5a78d1 100644 --- a/homeassistant/components/danfoss_air/manifest.json +++ b/homeassistant/components/danfoss_air/manifest.json @@ -3,7 +3,7 @@ "name": "Danfoss air", "documentation": "https://www.home-assistant.io/components/danfoss_air", "requirements": [ - "pydanfossair==0.0.7" + "pydanfossair==0.1.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index f5a7fd47f69..4e7fce28dc7 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -19,6 +19,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ReadCommand.boost, UpdateCommand.boost_activate, UpdateCommand.boost_deactivate], + ["Danfoss Air Bypass", + ReadCommand.bypass, + UpdateCommand.bypass_activate, + UpdateCommand.bypass_deactivate], + ["Danfoss Air Automatic Bypass", + ReadCommand.automatic_bypass, + UpdateCommand.bypass_activate, + UpdateCommand.bypass_deactivate], ] dev = [] @@ -59,7 +67,7 @@ class DanfossAir(SwitchDevice): def turn_off(self, **kwargs): """Turn the switch off.""" - _LOGGER.debug("Turning of switch with command %s", self._off_command) + _LOGGER.debug("Turning off switch with command %s", self._off_command) self._data.update_state(self._off_command, self._state_command) def update(self): diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index eebbb709a82..5f1ae46b48e 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -26,7 +26,7 @@ "title": "Definici\u00f3 de la passarel\u00b7la deCONZ" }, "link": { - "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ\n2. Prem el bot\u00f3 \"Desbloquejar passarel\u00b7la\"", + "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ -> Passarel\u00b7la -> Avan\u00e7at\n2. Prem el bot\u00f3 \"Autenticar applicaci\u00f3\"", "title": "Vincular amb deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json index 1588766e406..0f4bdf98ac1 100644 --- a/homeassistant/components/deconz/.translations/cs.json +++ b/homeassistant/components/deconz/.translations/cs.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Hostitel", - "port": "Port (v\u00fdchoz\u00ed hodnota: '80')" + "port": "Port" }, "title": "Definujte br\u00e1nu deCONZ" }, diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json index 0c3284e74b3..ca38deb28fe 100644 --- a/homeassistant/components/deconz/.translations/es.json +++ b/homeassistant/components/deconz/.translations/es.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "El puente ya esta configurado", "no_bridges": "No se han descubierto puentes deCONZ", - "one_instance_only": "El componente s\u00f3lo soporta una instancia deCONZ", + "one_instance_only": "El componente solo admite una instancia de deCONZ", "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, "error": { @@ -26,7 +26,7 @@ "title": "Definir pasarela deCONZ" }, "link": { - "description": "Desbloquee su pasarela deCONZ para registrarse con Home Assistant. \n\n 1. Ir a la configuraci\u00f3n del sistema deCONZ \n 2. Presione el bot\u00f3n \"Desbloquear Gateway\"", + "description": "Desbloquea tu gateway de deCONZ para registrarte con Home Assistant.\n\n1. Dir\u00edgete a deCONZ Settings -> Gateway -> Advanced\n2. Pulsa el bot\u00f3n \"Authenticate app\"", "title": "Enlazar con deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 59da47fc7ed..7934d20ec53 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Broen er allerede konfigurert", "no_bridges": "Ingen deCONZ broer oppdaget", - "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst" + "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst", + "updated_instance": "Oppdatert deCONZ forekomst med ny vertsadresse" }, "error": { "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel" @@ -25,7 +26,7 @@ "title": "Definer deCONZ-gatewayen" }, "link": { - "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger -> Gateway -> Avansert \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", "title": "Koble til deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index 022a3284c14..c3eded43341 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Mostek jest ju\u017c skonfigurowany", "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", - "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ" + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ", + "updated_instance": "Zaktualizowano instancj\u0119 deCONZ o nowy adres hosta" }, "error": { "no_key": "Nie mo\u017cna uzyska\u0107 klucza API" diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index c81d2f8989e..c4f2b2c4fab 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ", - "updated_instance": "deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d \u0441 \u043d\u043e\u0432\u044b\u043c \u0430\u0434\u0440\u0435\u0441\u043e\u043c \u0445\u043e\u0441\u0442\u0430" + "updated_instance": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d" }, "error": { "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API" diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index ae9329c2857..1a8550ca08f 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Most je \u017ee nastavljen", "no_bridges": "Ni odkritih mostov deCONZ", - "one_instance_only": "Komponenta podpira le en primerek deCONZ" + "one_instance_only": "Komponenta podpira le en primerek deCONZ", + "updated_instance": "Posodobljen deCONZ z novim naslovom gostitelja" }, "error": { "no_key": "Klju\u010da API ni mogo\u010de dobiti" diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index a1157cbfb9c..a5efd2a36d9 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -9,6 +9,12 @@ "no_key": "Det gick inte att ta emot en API-nyckel" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer" + }, + "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg" + }, "init": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 45a1b0c67e5..aa29e8c6b58 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -88,9 +88,11 @@ class DeconzCover(DeconzDevice, CoverDevice): """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] data = {'on': False} + if position > 0: data['on'] = True data['bri'] = int(position / 100 * 255) + await self._device.async_set_state(data) async def async_open_cover(self, **kwargs): @@ -126,7 +128,9 @@ class DeconzCoverZigbeeSpec(DeconzCover): """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] data = {'on': False} + if position < 100: data['on'] = True data['bri'] = 255 - int(position / 100 * 255) + await self._device.async_set_state(data) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 6923c93dd6f..73ac2499cd3 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -61,8 +61,10 @@ class DeconzDevice(Entity): if (self._device.uniqueid is None or self._device.uniqueid.count(':') != 7): return None + serial = self._device.uniqueid.split('-', 1)[0] bridgeid = self.gateway.api.config.bridgeid + return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 7514162fefa..c195703c36a 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -165,6 +165,8 @@ class DeconzLight(DeconzDevice, Light): """Return the device state attributes.""" attributes = {} attributes['is_deconz_group'] = self._device.type == 'LightGroup' + if self._device.type == 'LightGroup': attributes['all_on'] = self._device.all_on + return attributes diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index c68da4b566c..22947d40fb1 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "Deconz", "documentation": "https://www.home-assistant.io/components/deconz", "requirements": [ - "pydeconz==54" + "pydeconz==58" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index cb3f3b5b46a..5a97b43af86 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -51,7 +51,7 @@ class AbstractDemoPlayer(MediaPlayerDevice): # We only implement the methods that we support - def __init__(self, name): + def __init__(self, name, device_class=None): """Initialize the demo device.""" self._name = name self._player_state = STATE_PLAYING @@ -60,6 +60,7 @@ class AbstractDemoPlayer(MediaPlayerDevice): self._shuffle = False self._sound_mode_list = SOUND_MODE_LIST self._sound_mode = DEFAULT_SOUND_MODE + self._device_class = device_class @property def should_poll(self): @@ -101,6 +102,11 @@ class AbstractDemoPlayer(MediaPlayerDevice): """Return a list of available sound modes.""" return self._sound_mode_list + @property + def device_class(self): + """Return the device class of the media player.""" + return self._device_class + def turn_on(self): """Turn the media player on.""" self._player_state = STATE_PLAYING diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json index ee07635de4a..1d6a849f3a8 100644 --- a/homeassistant/components/dialogflow/.translations/es.json +++ b/homeassistant/components/dialogflow/.translations/es.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", + "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", "one_instance_allowed": "Solo una instancia es necesaria." }, "create_entry": { @@ -9,7 +9,8 @@ }, "step": { "user": { - "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?" + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Dialogflow?", + "title": "Configurar el Webhook de Dialogflow" } }, "title": "Dialogflow" diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 155e2b6806f..05b2a3c8e06 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -3,7 +3,7 @@ "name": "Discord", "documentation": "https://www.home-assistant.io/components/discord", "requirements": [ - "discord.py==0.16.12" + "discord.py==1.0.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index faf79d14e33..5a9cb77877d 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -1,5 +1,6 @@ """Discord platform for notify component.""" import logging +import os.path import voluptuous as vol @@ -33,36 +34,69 @@ class DiscordNotificationService(BaseNotificationService): self.token = token self.hass = hass + def file_exists(self, filename): + """Check if a file exists on disk and is in authorized path.""" + if not self.hass.config.is_allowed_path(filename): + return False + + return os.path.isfile(filename) + async def async_send_message(self, message, **kwargs): """Login to Discord, send message to channel(s) and log out.""" import discord discord.VoiceClient.warn_nacl = False discord_bot = discord.Client(loop=self.hass.loop) + images = None if ATTR_TARGET not in kwargs: _LOGGER.error("No target specified") return None + if ATTR_DATA in kwargs: + data = kwargs.get(ATTR_DATA) + + if ATTR_IMAGES in data: + images = list() + + for image in data.get(ATTR_IMAGES): + image_exists = await self.hass.async_add_executor_job( + self.file_exists, + image) + + if image_exists: + images.append(image) + else: + _LOGGER.warning("Image not found: %s", image) + # pylint: disable=unused-variable @discord_bot.event async def on_ready(): """Send the messages when the bot is ready.""" try: - data = kwargs.get(ATTR_DATA) - images = None - if data: - images = data.get(ATTR_IMAGES) for channelid in kwargs[ATTR_TARGET]: - channel = discord.Object(id=channelid) - await discord_bot.send_message(channel, message) + channelid = int(channelid) + channel = discord_bot.get_channel(channelid) + + if channel is None: + _LOGGER.warning( + "Channel not found for id: %s", + channelid) + continue + + # Must create new instances of File for each channel. + files = None if images: - for anum, f_name in enumerate(images): - await discord_bot.send_file(channel, f_name) + files = list() + for image in images: + files.append(discord.File(image)) + + await channel.send(message, files=files) except (discord.errors.HTTPException, discord.errors.NotFound) as error: _LOGGER.warning("Communication error: %s", error) await discord_bot.logout() await discord_bot.close() - await discord_bot.start(self.token) + # Using reconnect=False prevents multiple ready events to be fired. + await discord_bot.start(self.token, reconnect=False) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 7490b530926..900cbda74d4 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -105,16 +105,19 @@ OPTIONAL_SERVICE_HANDLERS = { SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), } +DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) +DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + CONF_IGNORE = 'ignore' CONF_ENABLE = 'enable' CONFIG_SCHEMA = vol.Schema({ vol.Optional(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): - vol.All(cv.ensure_list, [ - vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]), + vol.All(cv.ensure_list, [vol.In(DEFAULT_ENABLED)]), vol.Optional(CONF_ENABLE, default=[]): - vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)]) + vol.All(cv.ensure_list, [ + vol.In(DEFAULT_DISABLED + DEFAULT_ENABLED)]), }), }, extra=vol.ALLOW_EXTRA) @@ -140,6 +143,14 @@ async def async_setup(hass, config): ignored_platforms = [] enabled_platforms = [] + for platform in enabled_platforms: + if platform in DEFAULT_ENABLED: + logger.warning( + "Please remove %s from your discovery.enable configuration " + "as it is now enabled by default", + platform, + ) + async def new_service_found(service, info): """Handle a new service if one is found.""" if service in ignored_platforms: diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 2a92f1a0446..544ac9b0fba 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -3,7 +3,7 @@ "name": "Dnsip", "documentation": "https://www.home-assistant.io/components/dnsip", "requirements": [ - "aiodns==1.1.1" + "aiodns==2.0.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 976abb1401b..a29a0513cee 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -1,26 +1,26 @@ """Get your own public IP address or that of any host.""" -import logging from datetime import timedelta +import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_NAME = 'name' CONF_HOSTNAME = 'hostname' +CONF_IPV6 = 'ipv6' CONF_RESOLVER = 'resolver' CONF_RESOLVER_IPV6 = 'resolver_ipv6' -CONF_IPV6 = 'ipv6' -DEFAULT_NAME = 'myip' DEFAULT_HOSTNAME = 'myip.opendns.com' +DEFAULT_IPV6 = False +DEFAULT_NAME = 'myip' DEFAULT_RESOLVER = '208.67.222.222' DEFAULT_RESOLVER_IPV6 = '2620:0:ccc::2' -DEFAULT_IPV6 = False SCAN_INTERVAL = timedelta(seconds=120) @@ -33,8 +33,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -async def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the DNS IP sensor.""" hostname = config.get(CONF_HOSTNAME) name = config.get(CONF_NAME) @@ -57,8 +57,9 @@ class WanIpSensor(Entity): """Implementation of a DNS IP sensor.""" def __init__(self, hass, name, hostname, resolver, ipv6): - """Initialize the sensor.""" + """Initialize the DNS IP sensor.""" import aiodns + self.hass = hass self._name = name self.hostname = hostname @@ -80,9 +81,10 @@ class WanIpSensor(Entity): async def async_update(self): """Get the current DNS IP address for hostname.""" from aiodns.error import DNSError + try: - response = await self.resolver.query(self.hostname, - self.querytype) + response = await self.resolver.query( + self.hostname, self.querytype) except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) response = None diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py index a857d6657fd..fdba263d4ca 100644 --- a/homeassistant/components/dyson/__init__.py +++ b/homeassistant/components/dyson/__init__.py @@ -16,6 +16,7 @@ CONF_RETRY = 'retry' DEFAULT_TIMEOUT = 5 DEFAULT_RETRY = 10 DYSON_DEVICES = 'dyson_devices' +DYSON_PLATFORMS = ['sensor', 'fan', 'vacuum', 'climate', 'air_quality'] DOMAIN = 'dyson' @@ -91,9 +92,7 @@ def setup(hass, config): # Start fan/sensors components if hass.data[DYSON_DEVICES]: _LOGGER.debug("Starting sensor/fan components") - discovery.load_platform(hass, "sensor", DOMAIN, {}, config) - discovery.load_platform(hass, "fan", DOMAIN, {}, config) - discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) - discovery.load_platform(hass, "climate", DOMAIN, {}, config) + for platform in DYSON_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) return True diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py new file mode 100644 index 00000000000..238b8b6934d --- /dev/null +++ b/homeassistant/components/dyson/air_quality.py @@ -0,0 +1,126 @@ +"""Support for Dyson Pure Cool Air Quality Sensors.""" +import logging + +from homeassistant.components.air_quality import AirQualityEntity, DOMAIN +from . import DYSON_DEVICES + +ATTRIBUTION = 'Dyson purifier air quality sensor' + +_LOGGER = logging.getLogger(__name__) + +DYSON_AIQ_DEVICES = 'dyson_aiq_devices' + +ATTR_VOC = 'volatile_organic_compounds' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dyson Sensors.""" + from libpurecool.dyson_pure_cool import DysonPureCool + + if discovery_info is None: + return + + hass.data.setdefault(DYSON_AIQ_DEVICES, []) + + # Get Dyson Devices from parent component + device_ids = [device.unique_id for device in hass.data[DYSON_AIQ_DEVICES]] + for device in hass.data[DYSON_DEVICES]: + if isinstance(device, DysonPureCool) and \ + device.serial not in device_ids: + hass.data[DYSON_AIQ_DEVICES].append(DysonAirSensor(device)) + add_entities(hass.data[DYSON_AIQ_DEVICES]) + + +class DysonAirSensor(AirQualityEntity): + """Representation of a generic Dyson air quality sensor.""" + + def __init__(self, device): + """Create a new generic air quality Dyson sensor.""" + self._device = device + self._old_value = None + self._name = device.name + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_executor_job( + self._device.add_message_listener, self.on_message) + + def on_message(self, message): + """Handle new messages which are received from the fan.""" + from libpurecool.dyson_pure_state_v2 import \ + DysonEnvironmentalSensorV2State + + _LOGGER.debug('%s: Message received for %s device: %s', + DOMAIN, self.name, message) + if (self._old_value is None or + self._old_value != self._device.environmental_state) and \ + isinstance(message, DysonEnvironmentalSensorV2State): + self._old_value = self._device.environmental_state + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the Dyson sensor.""" + return self._name + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def air_quality_index(self): + """Return the Air Quality Index (AQI).""" + return max(self.particulate_matter_2_5, + self.particulate_matter_10, + self.nitrogen_dioxide, + self.volatile_organic_compounds) + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.particulate_matter_25) + return None + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.particulate_matter_10) + return None + + @property + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.nitrogen_dioxide) + return None + + @property + def volatile_organic_compounds(self): + """Return the VOC (Volatile Organic Compounds) level.""" + if self._device.environmental_state: + return int(self._device. + environmental_state.volatile_organic_compounds) + return None + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return self._device.serial + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + + voc = self.volatile_organic_compounds + if voc is not None: + data[ATTR_VOC] = voc + return data diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 03a55f8abbe..65ff093d6d5 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -474,7 +474,8 @@ class DysonPureCoolDevice(FanEntity): 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_9.value: SPEED_HIGH, + FanSpeed.FAN_SPEED_10.value: SPEED_HIGH} return speed_map[self._device.state.speed] diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index 56c924d1a54..9cd1c915c57 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -3,7 +3,6 @@ import logging from homeassistant.const import STATE_OFF, TEMP_CELSIUS from homeassistant.helpers.entity import Entity - from . import DYSON_DEVICES SENSOR_UNITS = { @@ -21,26 +20,38 @@ SENSOR_ICONS = { 'temperature': 'mdi:thermometer', } +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.""" - _LOGGER.debug("Creating new Dyson fans") - devices = [] - unit = hass.config.units.temperature_unit - # Get Dyson Devices from parent component - from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_cool_link import DysonPureCoolLink + from libpurecool.dyson_pure_cool import DysonPureCool - for device in [d for d in hass.data[DYSON_DEVICES] - if isinstance(d, DysonPureCoolLink) and - not isinstance(d, DysonPureCool)]: - devices.append(DysonFilterLifeSensor(device)) - devices.append(DysonDustSensor(device)) - devices.append(DysonHumiditySensor(device)) - devices.append(DysonTemperatureSensor(device, unit)) - devices.append(DysonAirQualitySensor(device)) + if discovery_info is None: + return + + hass.data.setdefault(DYSON_SENSOR_DEVICES, []) + unit = hass.config.units.temperature_unit + devices = hass.data[DYSON_SENSOR_DEVICES] + + # Get Dyson Devices from parent component + device_ids = [device.unique_id for device in + hass.data[DYSON_SENSOR_DEVICES]] + for device in hass.data[DYSON_DEVICES]: + if isinstance(device, DysonPureCool): + if '{}-{}'.format(device.serial, 'temperature') not in device_ids: + devices.append(DysonTemperatureSensor(device, unit)) + if '{}-{}'.format(device.serial, 'humidity') not in device_ids: + devices.append(DysonHumiditySensor(device)) + elif isinstance(device, DysonPureCoolLink): + devices.append(DysonFilterLifeSensor(device)) + devices.append(DysonDustSensor(device)) + devices.append(DysonHumiditySensor(device)) + devices.append(DysonTemperatureSensor(device, unit)) + devices.append(DysonAirQualitySensor(device)) add_entities(devices) @@ -56,7 +67,7 @@ class DysonSensor(Entity): async def async_added_to_hass(self): """Call when entity is added to hass.""" - self.hass.async_add_job( + self.hass.async_add_executor_job( self._device.add_message_listener, self.on_message) def on_message(self, message): @@ -88,6 +99,11 @@ class DysonSensor(Entity): """Return the icon for this sensor.""" return SENSOR_ICONS[self._sensor_type] + @property + def unique_id(self): + """Return the sensor's unique id.""" + return '{}-{}'.format(self._device.serial, self._sensor_type) + class DysonFilterLifeSensor(DysonSensor): """Representation of Dyson Filter Life sensor (in hours).""" diff --git a/homeassistant/components/ebusd/.translations/cs.json b/homeassistant/components/ebusd/.translations/cs.json new file mode 100644 index 00000000000..3ac4bf1cfa4 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/cs.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Den", + "night": "Noc" + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index bd2cd19d519..3ae6b1eac35 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -3,7 +3,7 @@ "name": "Econet", "documentation": "https://www.home-assistant.io/components/econet", "requirements": [ - "pyeconet==0.0.10" + "pyeconet==0.0.11" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index 9dfb58a5e34..b6a6719a37b 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -5,7 +5,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import CoreState, EventOrigin -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) EVENT_ROKU_COMMAND = 'roku_command' diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 2dcf6a3a0ac..9d51821082a 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -4,13 +4,13 @@ import logging import voluptuous as vol from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DOMAIN = 'enocean' - -ENOCEAN_DONGLE = None +DATA_ENOCEAN = 'enocean' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -18,14 +18,15 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +SIGNAL_RECEIVE_MESSAGE = 'enocean.receive_message' +SIGNAL_SEND_MESSAGE = 'enocean.send_message' + def setup(hass, config): """Set up the EnOcean component.""" - global ENOCEAN_DONGLE - serial_dev = config[DOMAIN].get(CONF_DEVICE) - - ENOCEAN_DONGLE = EnOceanDongle(hass, serial_dev) + dongle = EnOceanDongle(hass, serial_dev) + hass.data[DATA_ENOCEAN] = dongle return True @@ -39,87 +40,53 @@ class EnOceanDongle: self.__communicator = SerialCommunicator( port=ser, callback=self.callback) self.__communicator.start() - self.__devices = [] + self.hass = hass + self.hass.helpers.dispatcher.dispatcher_connect( + SIGNAL_SEND_MESSAGE, self._send_message_callback) - def register_device(self, dev): - """Register another device.""" - self.__devices.append(dev) - - def send_command(self, command): - """Send a command from the EnOcean dongle.""" + def _send_message_callback(self, command): + """Send a command through the EnOcean dongle.""" self.__communicator.send(command) - # pylint: disable=no-self-use - def _combine_hex(self, data): - """Combine list of integer values to one big integer.""" - output = 0x00 - for i, j in enumerate(reversed(data)): - output |= (j << i * 8) - return output - - def callback(self, temp): + def callback(self, packet): """Handle EnOcean device's callback. This is the callback function called by python-enocan whenever there is an incoming packet. """ from enocean.protocol.packet import RadioPacket - if isinstance(temp, RadioPacket): - _LOGGER.debug("Received radio packet: %s", temp) - rxtype = None - value = None - channel = 0 - if temp.data[6] == 0x30: - rxtype = "wallswitch" - value = 1 - elif temp.data[6] == 0x20: - rxtype = "wallswitch" - value = 0 - elif temp.data[4] == 0x0c: - rxtype = "power" - value = temp.data[3] + (temp.data[2] << 8) - elif temp.data[2] & 0x60 == 0x60: - rxtype = "switch_status" - channel = temp.data[2] & 0x1F - if temp.data[3] == 0xe4: - value = 1 - elif temp.data[3] == 0x80: - value = 0 - elif temp.data[0] == 0xa5 and temp.data[1] == 0x02: - rxtype = "dimmerstatus" - value = temp.data[2] - for device in self.__devices: - if rxtype == "wallswitch" and device.stype == "listener": - if temp.sender_int == self._combine_hex(device.dev_id): - device.value_changed(value, temp.data[1]) - if rxtype == "power" and device.stype == "powersensor": - if temp.sender_int == self._combine_hex(device.dev_id): - device.value_changed(value) - if rxtype == "power" and device.stype == "switch": - if temp.sender_int == self._combine_hex(device.dev_id): - if value > 10: - device.value_changed(1) - if rxtype == "switch_status" and device.stype == "switch" and \ - channel == device.channel: - if temp.sender_int == self._combine_hex(device.dev_id): - device.value_changed(value) - if rxtype == "dimmerstatus" and device.stype == "dimmer": - if temp.sender_int == self._combine_hex(device.dev_id): - device.value_changed(value) + if isinstance(packet, RadioPacket): + _LOGGER.debug("Received radio packet: %s", packet) + self.hass.helpers.dispatcher.dispatcher_send( + SIGNAL_RECEIVE_MESSAGE, packet) -class EnOceanDevice(): +class EnOceanDevice(Entity): """Parent class for all devices associated with the EnOcean component.""" - def __init__(self): + def __init__(self, dev_id, dev_name="EnOcean device"): """Initialize the device.""" - ENOCEAN_DONGLE.register_device(self) - self.stype = "" - self.sensorid = [0x00, 0x00, 0x00, 0x00] + self.dev_id = dev_id + self.dev_name = dev_name + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RECEIVE_MESSAGE, self._message_received_callback) + + def _message_received_callback(self, packet): + """Handle incoming packets.""" + from enocean.utils import combine_hex + if packet.sender_int == combine_hex(self.dev_id): + self.value_changed(packet) + + def value_changed(self, packet): + """Update the internal state of the device when a packet arrives.""" # pylint: disable=no-self-use def send_command(self, data, optional, packet_type): """Send a command via the EnOcean dongle.""" from enocean.protocol.packet import Packet packet = Packet(packet_type, data=data, optional=optional) - ENOCEAN_DONGLE.send_command(packet) + self.hass.helpers.dispatcher.dispatcher_send( + SIGNAL_SEND_MESSAGE, packet) diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 649bec024e3..5e0a3b31817 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -3,16 +3,17 @@ import logging import voluptuous as vol -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) from homeassistant.components import enocean -from homeassistant.const import ( - CONF_NAME, CONF_ID, CONF_DEVICE_CLASS) +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'EnOcean binary sensor' +DEPENDENCIES = ['enocean'] +EVENT_BUTTON_PRESSED = 'button_pressed' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), @@ -24,61 +25,80 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Binary Sensor platform for EnOcean.""" dev_id = config.get(CONF_ID) - devname = config.get(CONF_NAME) + dev_name = config.get(CONF_NAME) device_class = config.get(CONF_DEVICE_CLASS) - add_entities([EnOceanBinarySensor(dev_id, devname, device_class)]) + add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): - """Representation of EnOcean binary sensors such as wall switches.""" + """Representation of EnOcean binary sensors such as wall switches. - def __init__(self, dev_id, devname, device_class): + Supported EEPs (EnOcean Equipment Profiles): + - F6-02-01 (Light and Blind Control - Application Style 2) + - F6-02-02 (Light and Blind Control - Application Style 1) + """ + + def __init__(self, dev_id, dev_name, device_class): """Initialize the EnOcean binary sensor.""" - enocean.EnOceanDevice.__init__(self) - self.stype = 'listener' - self.dev_id = dev_id + super().__init__(dev_id, dev_name) + self._device_class = device_class self.which = -1 self.onoff = -1 - self.devname = devname - self._device_class = device_class @property def name(self): """Return the default name for the binary sensor.""" - return self.devname + return self.dev_name @property def device_class(self): """Return the class of this sensor.""" return self._device_class - def value_changed(self, value, value2): + def value_changed(self, packet): """Fire an event with the data that have changed. This method is called when there is an incoming packet associated with this platform. + + Example packet data: + - 2nd button pressed + ['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30'] + - button released + ['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20'] """ + # Energy Bow + pushed = None + + if packet.data[6] == 0x30: + pushed = 1 + elif packet.data[6] == 0x20: + pushed = 0 + self.schedule_update_ha_state() - if value2 == 0x70: + + action = packet.data[1] + if action == 0x70: self.which = 0 self.onoff = 0 - elif value2 == 0x50: + elif action == 0x50: self.which = 0 self.onoff = 1 - elif value2 == 0x30: + elif action == 0x30: self.which = 1 self.onoff = 0 - elif value2 == 0x10: + elif action == 0x10: self.which = 1 self.onoff = 1 - elif value2 == 0x37: + elif action == 0x37: self.which = 10 self.onoff = 0 - elif value2 == 0x15: + elif action == 0x15: self.which = 10 self.onoff = 1 - self.hass.bus.fire('button_pressed', {'id': self.dev_id, - 'pushed': value, - 'which': self.which, - 'onoff': self.onoff}) + self.hass.bus.fire(EVENT_BUTTON_PRESSED, + {'id': self.dev_id, + 'pushed': pushed, + 'which': self.which, + 'onoff': self.onoff}) diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 9ec3f4ab27b..d40b2c01df6 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -4,10 +4,10 @@ import math import voluptuous as vol -from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, CONF_ID) from homeassistant.components import enocean +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) +from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -28,29 +28,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the EnOcean light platform.""" sender_id = config.get(CONF_SENDER_ID) - devname = config.get(CONF_NAME) + dev_name = config.get(CONF_NAME) dev_id = config.get(CONF_ID) - add_entities([EnOceanLight(sender_id, devname, dev_id)]) + add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) class EnOceanLight(enocean.EnOceanDevice, Light): """Representation of an EnOcean light source.""" - def __init__(self, sender_id, devname, dev_id): + def __init__(self, sender_id, dev_id, dev_name): """Initialize the EnOcean light source.""" - enocean.EnOceanDevice.__init__(self) + super().__init__(dev_id, dev_name) self._on_state = False self._brightness = 50 self._sender_id = sender_id - self.dev_id = dev_id - self._devname = devname - self.stype = 'dimmer' @property def name(self): """Return the name of the device if any.""" - return self._devname + return self.dev_name @property def brightness(self): @@ -94,8 +91,14 @@ class EnOceanLight(enocean.EnOceanDevice, Light): self.send_command(command, [], 0x01) self._on_state = False - def value_changed(self, val): - """Update the internal state of this device.""" - self._brightness = math.floor(val / 100.0 * 256.0) - self._on_state = bool(val != 0) - self.schedule_update_ha_state() + def value_changed(self, packet): + """Update the internal state of this device. + + Dimmer devices like Eltako FUD61 send telegram in different RORGs. + We only care about the 4BS (0xA5). + """ + if packet.data[0] == 0xa5 and packet.data[1] == 0x02: + val = packet.data[2] + self._brightness = math.floor(val / 100.0 * 256.0) + self._on_state = bool(val != 0) + self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index 7c4d7c0b8d9..e6f1c5d7826 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -3,8 +3,8 @@ "name": "Enocean", "documentation": "https://www.home-assistant.io/components/enocean", "requirements": [ - "enocean==0.40" + "enocean==0.50" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@bdurrer"] } diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 530738e1f88..62d0277946f 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -3,58 +3,201 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_ID, POWER_WATT) -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv from homeassistant.components import enocean +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ID, CONF_NAME, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, POWER_WATT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_MAX_TEMP = 'max_temp' +CONF_MIN_TEMP = 'min_temp' +CONF_RANGE_FROM = 'range_from' +CONF_RANGE_TO = 'range_to' + DEFAULT_NAME = 'EnOcean sensor' + +DEVICE_CLASS_POWER = 'powersensor' + +SENSOR_TYPES = { + DEVICE_CLASS_HUMIDITY: { + 'name': 'Humidity', + 'unit': '%', + 'icon': 'mdi:water-percent', + 'class': DEVICE_CLASS_HUMIDITY, + }, + DEVICE_CLASS_POWER: { + 'name': 'Power', + 'unit': POWER_WATT, + 'icon': 'mdi:power-plug', + 'class': DEVICE_CLASS_POWER, + }, + DEVICE_CLASS_TEMPERATURE: { + 'name': 'Temperature', + 'unit': TEMP_CELSIUS, + 'icon': 'mdi:thermometer', + 'class': DEVICE_CLASS_TEMPERATURE, + }, +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEVICE_CLASS_POWER): cv.string, + vol.Optional(CONF_MAX_TEMP, default=40): vol.Coerce(int), + vol.Optional(CONF_MIN_TEMP, default=0): vol.Coerce(int), + vol.Optional(CONF_RANGE_FROM, default=255): cv.positive_int, + vol.Optional(CONF_RANGE_TO, default=0): cv.positive_int, }) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an EnOcean sensor device.""" dev_id = config.get(CONF_ID) - devname = config.get(CONF_NAME) + dev_name = config.get(CONF_NAME) + dev_class = config.get(CONF_DEVICE_CLASS) - add_entities([EnOceanSensor(dev_id, devname)]) + if dev_class == DEVICE_CLASS_TEMPERATURE: + temp_min = config.get(CONF_MIN_TEMP) + temp_max = config.get(CONF_MAX_TEMP) + range_from = config.get(CONF_RANGE_FROM) + range_to = config.get(CONF_RANGE_TO) + add_entities([EnOceanTemperatureSensor( + dev_id, dev_name, temp_min, temp_max, range_from, range_to)]) + + elif dev_class == DEVICE_CLASS_HUMIDITY: + add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) + + elif dev_class == DEVICE_CLASS_POWER: + add_entities([EnOceanPowerSensor(dev_id, dev_name)]) -class EnOceanSensor(enocean.EnOceanDevice, Entity): - """Representation of an EnOcean sensor device such as a power meter.""" +class EnOceanSensor(enocean.EnOceanDevice): + """Representation of an EnOcean sensor device such as a power meter.""" - def __init__(self, dev_id, devname): + def __init__(self, dev_id, dev_name, sensor_type): """Initialize the EnOcean sensor device.""" - enocean.EnOceanDevice.__init__(self) - self.stype = "powersensor" - self.power = None - self.dev_id = dev_id - self.which = -1 - self.onoff = -1 - self.devname = devname + super().__init__(dev_id, dev_name) + self._sensor_type = sensor_type + self._device_class = SENSOR_TYPES[self._sensor_type]['class'] + self._dev_name = '{} {}'.format( + SENSOR_TYPES[self._sensor_type]['name'], dev_name) + self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]['unit'] + self._icon = SENSOR_TYPES[self._sensor_type]['icon'] + self._state = None @property def name(self): """Return the name of the device.""" - return 'Power %s' % self.devname + return self._dev_name - def value_changed(self, value): - """Update the internal state of the device.""" - self.power = value - self.schedule_update_ha_state() + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class @property def state(self): """Return the state of the device.""" - return self.power + return self._state @property def unit_of_measurement(self): """Return the unit of measurement.""" - return POWER_WATT + return self._unit_of_measurement + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + + +class EnOceanPowerSensor(EnOceanSensor): + """Representation of an EnOcean power sensor. + + EEPs (EnOcean Equipment Profiles): + - A5-12-01 (Automated Meter Reading, Electricity) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean power sensor device.""" + super().__init__(dev_id, dev_name, DEVICE_CLASS_POWER) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.rorg != 0xA5: + return + packet.parse_eep(0x12, 0x01) + if packet.parsed['DT']['raw_value'] == 1: + # this packet reports the current value + raw_val = packet.parsed['MR']['raw_value'] + divisor = packet.parsed['DIV']['raw_value'] + self._state = raw_val / (10 ** divisor) + self.schedule_update_ha_state() + + +class EnOceanTemperatureSensor(EnOceanSensor): + """Representation of an EnOcean temperature sensor device. + + EEPs (EnOcean Equipment Profiles): + - A5-02-01 to A5-02-1B All 8 Bit Temperature Sensors of A5-02 + - A5-10-01 to A5-10-14 (Room Operating Panels) + - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%) + - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%) + - A5-10-10 (Temp. and Humidity Sensor and Set Point) + - A5-10-12 (Temp. and Humidity Sensor, Set Point and Occupancy Control) + - 10 Bit Temp. Sensors are not supported (A5-02-20, A5-02-30) + + For the following EEPs the scales must be set to "0 to 250": + - A5-04-01 + - A5-04-02 + - A5-10-10 to A5-10-14 + """ + + def __init__(self, dev_id, dev_name, scale_min, scale_max, + range_from, range_to): + """Initialize the EnOcean temperature sensor device.""" + super().__init__(dev_id, dev_name, DEVICE_CLASS_TEMPERATURE) + self._scale_min = scale_min + self._scale_max = scale_max + self.range_from = range_from + self.range_to = range_to + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.data[0] != 0xa5: + return + temp_scale = self._scale_max - self._scale_min + temp_range = self.range_to - self.range_from + raw_val = packet.data[3] + temperature = temp_scale / temp_range * (raw_val - self.range_from) + temperature += self._scale_min + self._state = round(temperature, 1) + self.schedule_update_ha_state() + + +class EnOceanHumiditySensor(EnOceanSensor): + """Representation of an EnOcean humidity sensor device. + + EEPs (EnOcean Equipment Profiles): + - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%) + - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%) + - A5-10-10 to A5-10-14 (Room Operating Panels) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean humidity sensor device.""" + super().__init__(dev_id, dev_name, DEVICE_CLASS_HUMIDITY) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.rorg != 0xA5: + return + humidity = packet.data[2] * 100 / 250 + self._state = round(humidity, 1) + self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index f0b132c9d1c..48d53949a47 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -3,16 +3,16 @@ import logging import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_ID) from homeassistant.components import enocean -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'EnOcean Switch' CONF_CHANNEL = 'channel' +DEFAULT_NAME = 'EnOcean Switch' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), @@ -23,26 +23,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the EnOcean switch platform.""" - dev_id = config.get(CONF_ID) - devname = config.get(CONF_NAME) channel = config.get(CONF_CHANNEL) + dev_id = config.get(CONF_ID) + dev_name = config.get(CONF_NAME) - add_entities([EnOceanSwitch(dev_id, devname, channel)]) + add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, devname, channel): + def __init__(self, dev_id, dev_name, channel): """Initialize the EnOcean switch device.""" - enocean.EnOceanDevice.__init__(self) - self.dev_id = dev_id - self._devname = devname + super().__init__(dev_id, dev_name) self._light = None self._on_state = False self._on_state2 = False self.channel = channel - self.stype = "switch" @property def is_on(self): @@ -52,7 +49,7 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): @property def name(self): """Return the device name.""" - return self._devname + return self.dev_name def turn_on(self, **kwargs): """Turn on the switch.""" @@ -74,7 +71,24 @@ class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): packet_type=0x01) self._on_state = False - def value_changed(self, val): + def value_changed(self, packet): """Update the internal state of the switch.""" - self._on_state = val - self.schedule_update_ha_state() + if packet.data[0] == 0xa5: + # power meter telegram, turn on if > 10 watts + packet.parse_eep(0x12, 0x01) + if packet.parsed['DT']['raw_value'] == 1: + raw_val = packet.parsed['MR']['raw_value'] + divisor = packet.parsed['DIV']['raw_value'] + watts = raw_val / (10 ** divisor) + if watts > 1: + self._on_state = True + self.schedule_update_ha_state() + elif packet.data[0] == 0xd2: + # actuator status telegram + packet.parse_eep(0x01, 0x01) + if packet.parsed['CMD']['raw_value'] == 4: + channel = packet.parsed['IO']['raw_value'] + output = packet.parsed['OV']['raw_value'] + if channel == self.channel: + self._on_state = output > 0 + self.schedule_update_ha_state() diff --git a/homeassistant/components/epsonworkforce/manifest.json b/homeassistant/components/epsonworkforce/manifest.json index 5f39c414725..21f76c3a31f 100644 --- a/homeassistant/components/epsonworkforce/manifest.json +++ b/homeassistant/components/epsonworkforce/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/components/epsonworkforce", "dependencies": [], "codeowners": ["@ThaStealth"], - "requirements": ["epsonprinter==0.0.8"] + "requirements": ["epsonprinter==0.0.9"] } diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 6abf04d8aaa..4f9ea4a1dd0 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -10,15 +10,16 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['epsonprinter==0.0.8'] +REQUIREMENTS = ['epsonprinter==0.0.9'] _LOGGER = logging.getLogger(__name__) MONITORED_CONDITIONS = { - 'black': ['Inklevel Black', '%', 'mdi:water'], - 'magenta': ['Inklevel Magenta', '%', 'mdi:water'], - 'cyan': ['Inklevel Cyan', '%', 'mdi:water'], - 'yellow': ['Inklevel Yellow', '%', 'mdi:water'], - 'clean': ['Inklevel Cleaning', '%', 'mdi:water'], + 'black': ['Ink level Black', '%', 'mdi:water'], + 'photoblack': ['Ink level Photoblack', '%', 'mdi:water'], + 'magenta': ['Ink level Magenta', '%', 'mdi:water'], + 'cyan': ['Ink level Cyan', '%', 'mdi:water'], + 'yellow': ['Ink level Yellow', '%', 'mdi:water'], + 'clean': ['Cleaning level', '%', 'mdi:water'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/esphome/.translations/ca.json b/homeassistant/components/esphome/.translations/ca.json index 74d2c1d4b9a..f9c60979c8d 100644 --- a/homeassistant/components/esphome/.translations/ca.json +++ b/homeassistant/components/esphome/.translations/ca.json @@ -13,7 +13,7 @@ "data": { "password": "Contrasenya" }, - "description": "Introdueix la contrasenya que has posat en la teva configuraci\u00f3.", + "description": "Introdueix la contrasenya que has posat en la teva configuraci\u00f3 com a {name}.", "title": "Introdueix la contrasenya" }, "discovery_confirm": { diff --git a/homeassistant/components/esphome/.translations/cs.json b/homeassistant/components/esphome/.translations/cs.json new file mode 100644 index 00000000000..081275d3def --- /dev/null +++ b/homeassistant/components/esphome/.translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "authenticate": { + "description": "Zadejte heslo, kter\u00e9 jste nastavili ve va\u0161\u00ed konfiguraci pro {name} ." + }, + "discovery_confirm": { + "description": "Chcete do domovsk\u00e9ho asistenta p\u0159idat uzel ESPHome `{name}`?", + "title": "Nalezen uzel ESPHome" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/es.json b/homeassistant/components/esphome/.translations/es.json index ea79edc0b31..88730a18554 100644 --- a/homeassistant/components/esphome/.translations/es.json +++ b/homeassistant/components/esphome/.translations/es.json @@ -4,14 +4,16 @@ "already_configured": "ESP ya est\u00e1 configurado" }, "error": { - "invalid_password": "\u00a1Contrase\u00f1a incorrecta!" + "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.", + "invalid_password": "\u00a1Contrase\u00f1a incorrecta!", + "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { "data": { "password": "Contrase\u00f1a" }, - "description": "Escribe la contrase\u00f1a que hayas establecido en tu configuraci\u00f3n.", + "description": "Escribe la contrase\u00f1a que hayas puesto en la configuraci\u00f3n para {name}.", "title": "Escribe la contrase\u00f1a" }, "discovery_confirm": { @@ -23,6 +25,7 @@ "host": "Host", "port": "Puerto" }, + "description": "Introduce la configuraci\u00f3n de la conexi\u00f3n de tu nodo [ESPHome](https://esphomelib.com/).", "title": "ESPHome" } }, diff --git a/homeassistant/components/essent/__init__.py b/homeassistant/components/essent/__init__.py new file mode 100644 index 00000000000..42e867c6d21 --- /dev/null +++ b/homeassistant/components/essent/__init__.py @@ -0,0 +1 @@ +"""The Essent component.""" diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json new file mode 100644 index 00000000000..49189f6bacb --- /dev/null +++ b/homeassistant/components/essent/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "essent", + "name": "Essent", + "documentation": "https://www.home-assistant.io/components/essent", + "requirements": ["PyEssent==0.10"], + "dependencies": [], + "codeowners": ["@TheLastProject"] +} diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py new file mode 100644 index 00000000000..545ed3d5baf --- /dev/null +++ b/homeassistant/components/essent/sensor.py @@ -0,0 +1,112 @@ +"""Support for Essent API.""" +from datetime import timedelta + +from pyessent import PyEssent +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, ENERGY_KILO_WATT_HOUR) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +SCAN_INTERVAL = timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Essent platform.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + essent = EssentBase(username, password) + meters = [] + for meter in essent.retrieve_meters(): + data = essent.retrieve_meter_data(meter) + for tariff in data['values']['LVR'].keys(): + meters.append(EssentMeter( + essent, + meter, + data['type'], + tariff, + data['values']['LVR'][tariff]['unit'])) + + add_devices(meters, True) + + +class EssentBase(): + """Essent Base.""" + + def __init__(self, username, password): + """Initialize the Essent API.""" + self._username = username + self._password = password + self._meters = [] + self._meter_data = {} + + self.update() + + def retrieve_meters(self): + """Retrieve the list of meters.""" + return self._meters + + def retrieve_meter_data(self, meter): + """Retrieve the data for this meter.""" + return self._meter_data[meter] + + @Throttle(timedelta(minutes=30)) + def update(self): + """Retrieve the latest meter data from Essent.""" + essent = PyEssent(self._username, self._password) + self._meters = essent.get_EANs() + for meter in self._meters: + self._meter_data[meter] = essent.read_meter( + meter, only_last_meter_reading=True) + + +class EssentMeter(Entity): + """Representation of Essent measurements.""" + + def __init__(self, essent_base, meter, meter_type, tariff, unit): + """Initialize the sensor.""" + self._state = None + self._essent_base = essent_base + self._meter = meter + self._type = meter_type + self._tariff = tariff + self._unit = unit + + @property + def name(self): + """Return the name of the sensor.""" + return "Essent {} ({})".format(self._type, self._tariff) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self._unit.lower() == 'kwh': + return ENERGY_KILO_WATT_HOUR + + return self._unit + + def update(self): + """Fetch the energy usage.""" + # Ensure our data isn't too old + self._essent_base.update() + + # Retrieve our meter + data = self._essent_base.retrieve_meter_data(self._meter) + + # Set our value + self._state = next( + iter(data['values']['LVR'][self._tariff]['records'].values())) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 459a3636a06..562a32b07c6 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -5,26 +5,31 @@ # 0-12 Heating zones (a.k.a. Zone), and # 0-1 DHW controller, (a.k.a. Boiler) # The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater -from datetime import timedelta +from datetime import datetime, timedelta import logging import requests.exceptions import voluptuous as vol +import evohomeclient2 + from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, + HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, + PRECISION_HALVES, TEMP_CELSIUS) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity + +from .const import ( + DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) _LOGGER = logging.getLogger(__name__) -DOMAIN = 'evohome' -DATA_EVOHOME = 'data_' + DOMAIN -DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN - CONF_LOCATION_IDX = 'location_idx' SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) @@ -43,10 +48,6 @@ CONF_SECRETS = [ CONF_USERNAME, CONF_PASSWORD, ] -# These are used to help prevent E501 (line too long) violations. -GWS = 'gateways' -TCS = 'temperatureControlSystems' - # bit masks for dispatcher packets EVO_PARENT = 0x01 EVO_CHILD = 0x02 @@ -66,8 +67,6 @@ def setup(hass, hass_config): scan_interval = timedelta( minutes=(scan_interval.total_seconds() + 59) // 60) - import evohomeclient2 - try: client = evo_data['client'] = evohomeclient2.EvohomeClient( evo_data['params'][CONF_USERNAME], @@ -145,3 +144,129 @@ def setup(hass, hass_config): hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True + + +class EvoDevice(Entity): + """Base for any Honeywell evohome device. + + Such devices include the Controller, (up to 12) Heating Zones and + (optionally) a DHW controller. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome entity.""" + self._client = client + self._obj = obj_ref + + self._name = None + self._icon = None + self._type = None + + self._supported_features = None + self._operation_list = None + + self._params = evo_data['params'] + self._timers = evo_data['timers'] + self._status = {} + + self._available = False # should become True after first update() + + @callback + def _connect(self, packet): + if packet['to'] & self._type and packet['signal'] == 'refresh': + self.async_schedule_update_ha_state(force_refresh=True) + + def _handle_exception(self, err): + try: + raise err + + except evohomeclient2.AuthenticationError: + _LOGGER.error( + "Failed to (re)authenticate with the vendor's server. " + "This may be a temporary error. Message is: %s", + err + ) + + except requests.exceptions.ConnectionError: + # this appears to be common with Honeywell's servers + _LOGGER.warning( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's status page." + ) + + except requests.exceptions.HTTPError: + if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.warning( + "Vendor says their server is currently unavailable. " + "This may be temporary; check the vendor's status page." + ) + + elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "The vendor's API rate limit has been exceeded. " + "So will cease polling, and will resume after %s seconds.", + (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds() + ) + self._timers['statusUpdated'] = datetime.now() + \ + self._params[CONF_SCAN_INTERVAL] * 3 + + else: + raise # we don't expect/handle any other HTTPErrors + + # These properties, methods are from the Entity class + async def async_added_to_hass(self): + """Run when entity about to be added.""" + async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) + + @property + def should_poll(self) -> bool: + """Most evohome devices push their state to HA. + + Only the Controller should be polled. + """ + return False + + @property + def name(self) -> str: + """Return the name to use in the frontend UI.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome device. + + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. + """ + return {'status': self._status} + + @property + def icon(self): + """Return the icon to use in the frontend UI.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if the device is currently available.""" + return self._available + + @property + def supported_features(self): + """Get the list of supported features of the device.""" + return self._supported_features + + # These properties are common to ClimateDevice, WaterHeaterDevice classes + @property + def precision(self): + """Return the temperature precision to use in the frontend UI.""" + return PRECISION_HALVES + + @property + def temperature_unit(self): + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + @property + def operation_list(self): + """Return the list of available operations.""" + return self._operation_list diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index cf6c21df10f..3e8aefe39c4 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -4,20 +4,21 @@ import logging import requests.exceptions +import evohomeclient2 + from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( - CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, - PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) + CONF_SCAN_INTERVAL, STATE_OFF,) +from homeassistant.helpers.dispatcher import dispatcher_send from . import ( - CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT, - GWS, TCS) + EvoDevice, + CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT) +from .const import ( + DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) _LOGGER = logging.getLogger(__name__) @@ -103,115 +104,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities, async_add_entities(entities, update_before_add=False) -class EvoClimateDevice(ClimateDevice): - """Base for a Honeywell evohome Climate device.""" - - # pylint: disable=no-member - - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity.""" - self._client = client - self._obj = obj_ref - - self._params = evo_data['params'] - self._timers = evo_data['timers'] - self._status = {} - - self._available = False # should become True after first update() - - async def async_added_to_hass(self): - """Run when entity about to be added.""" - async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) - - @callback - def _connect(self, packet): - if packet['to'] & self._type and packet['signal'] == 'refresh': - self.async_schedule_update_ha_state(force_refresh=True) - - def _handle_exception(self, err): - try: - import evohomeclient2 - raise err - - except evohomeclient2.AuthenticationError: - _LOGGER.error( - "Failed to (re)authenticate with the vendor's server. " - "This may be a temporary error. Message is: %s", - err - ) - - except requests.exceptions.ConnectionError: - # this appears to be common with Honeywell's servers - _LOGGER.warning( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's status page." - ) - - except requests.exceptions.HTTPError: - if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: - _LOGGER.warning( - "Vendor says their server is currently unavailable. " - "This may be temporary; check the vendor's status page." - ) - - elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: - _LOGGER.warning( - "The vendor's API rate limit has been exceeded. " - "So will cease polling, and will resume after %s seconds.", - (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds() - ) - self._timers['statusUpdated'] = datetime.now() + \ - self._params[CONF_SCAN_INTERVAL] * 3 - - else: - raise # we don't expect/handle any other HTTPErrors - - @property - def name(self) -> str: - """Return the name to use in the frontend UI.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend UI.""" - return self._icon - - @property - def device_state_attributes(self): - """Return the device state attributes of the evohome Climate device. - - This is state data that is not available otherwise, due to the - restrictions placed upon ClimateDevice properties, etc. by HA. - """ - return {'status': self._status} - - @property - def available(self) -> bool: - """Return True if the device is currently available.""" - return self._available - - @property - def supported_features(self): - """Get the list of supported features of the device.""" - return self._supported_features - - @property - def operation_list(self): - """Return the list of available operations.""" - return self._operation_list - - @property - def temperature_unit(self): - """Return the temperature unit to use in the frontend UI.""" - return TEMP_CELSIUS - - @property - def precision(self): - """Return the temperature precision to use in the frontend UI.""" - return PRECISION_HALVES - - -class EvoZone(EvoClimateDevice): +class EvoZone(EvoDevice, ClimateDevice): """Base for a Honeywell evohome Zone device.""" def __init__(self, evo_data, client, obj_ref): @@ -235,33 +128,6 @@ class EvoZone(EvoClimateDevice): SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_ON_OFF - @property - def min_temp(self): - """Return the minimum target temperature of a evohome Zone. - - The default is 5 (in Celsius), but it is configurable within 5-35. - """ - return self._config['setpointCapabilities']['minHeatSetpoint'] - - @property - def max_temp(self): - """Return the minimum target temperature of a evohome Zone. - - The default is 35 (in Celsius), but it is configurable within 5-35. - """ - return self._config['setpointCapabilities']['maxHeatSetpoint'] - - @property - def target_temperature(self): - """Return the target temperature of the evohome Zone.""" - return self._status['setpointStatus']['targetHeatTemperature'] - - @property - def current_temperature(self): - """Return the current temperature of the evohome Zone.""" - return (self._status['temperatureStatus']['temperature'] - if self._status['temperatureStatus']['isAvailable'] else None) - @property def current_operation(self): """Return the current operating mode of the evohome Zone. @@ -285,6 +151,17 @@ class EvoZone(EvoClimateDevice): return current_operation + @property + def current_temperature(self): + """Return the current temperature of the evohome Zone.""" + return (self._status['temperatureStatus']['temperature'] + if self._status['temperatureStatus']['isAvailable'] else None) + + @property + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] + @property def is_on(self) -> bool: """Return True if the evohome Zone is off. @@ -297,6 +174,22 @@ class EvoZone(EvoClimateDevice): self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER return not is_off + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['minHeatSetpoint'] + + @property + def max_temp(self): + """Return the maximum target temperature of a evohome Zone. + + The default is 35 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['maxHeatSetpoint'] + def _set_temperature(self, temperature, until=None): """Set the new target temperature of a Zone. @@ -305,7 +198,6 @@ class EvoZone(EvoClimateDevice): - None for PermanentOverride (i.e. indefinitely) """ try: - import evohomeclient2 self._obj.set_temperature(temperature, until) except (requests.exceptions.RequestException, evohomeclient2.AuthenticationError) as err: @@ -330,6 +222,29 @@ class EvoZone(EvoClimateDevice): """ self._set_temperature(self.min_temp, until=None) + def _set_operation_mode(self, operation_mode): + if operation_mode == EVO_FOLLOW: + try: + self._obj.cancel_temp_override() + except (requests.exceptions.RequestException, + evohomeclient2.AuthenticationError) as err: + self._handle_exception(err) + + elif operation_mode == EVO_TEMPOVER: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not yet implemented", + operation_mode + ) + + elif operation_mode == EVO_PERMOVER: + self._set_temperature(self.target_temperature, until=None) + + else: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not valid", + operation_mode + ) + def set_operation_mode(self, operation_mode): """Set an operating mode for a Zone. @@ -354,38 +269,6 @@ class EvoZone(EvoClimateDevice): """ self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) - def _set_operation_mode(self, operation_mode): - if operation_mode == EVO_FOLLOW: - try: - import evohomeclient2 - self._obj.cancel_temp_override() - except (requests.exceptions.RequestException, - evohomeclient2.AuthenticationError) as err: - self._handle_exception(err) - - elif operation_mode == EVO_TEMPOVER: - _LOGGER.error( - "_set_operation_mode(op_mode=%s): mode not yet implemented", - operation_mode - ) - - elif operation_mode == EVO_PERMOVER: - self._set_temperature(self.target_temperature, until=None) - - else: - _LOGGER.error( - "_set_operation_mode(op_mode=%s): mode not valid", - operation_mode - ) - - @property - def should_poll(self) -> bool: - """Return False as evohome child devices should never be polled. - - The evohome Controller will inform its children when to update(). - """ - return False - def update(self): """Process the evohome Zone's state data.""" evo_data = self.hass.data[DATA_EVOHOME] @@ -398,7 +281,7 @@ class EvoZone(EvoClimateDevice): self._available = True -class EvoController(EvoClimateDevice): +class EvoController(EvoDevice, ClimateDevice): """Base for a Honeywell evohome hub/Controller device. The Controller (aka TCS, temperature control system) is the parent of all @@ -445,22 +328,18 @@ class EvoController(EvoClimateDevice): return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) @property - def min_temp(self): - """Return the minimum target temperature of a evohome Controller. + def current_temperature(self): + """Return the average current temperature of the Heating/DHW zones. - Although evohome Controllers do not have a minimum target temp, one is - expected by the HA schema; the default for an evohome HR92 is used. + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. """ - return 5 + tmp_list = [x for x in self._status['zones'] + if x['temperatureStatus']['isAvailable']] + temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] - @property - def max_temp(self): - """Return the minimum target temperature of a evohome Controller. - - Although evohome Controllers do not have a maximum target temp, one is - expected by the HA schema; the default for an evohome HR92 is used. - """ - return 35 + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp @property def target_temperature(self): @@ -476,18 +355,9 @@ class EvoController(EvoClimateDevice): return avg_temp @property - def current_temperature(self): - """Return the average current temperature of the Heating/DHW zones. - - Although evohome Controllers do not have a target temp, one is - expected by the HA schema. - """ - tmp_list = [x for x in self._status['zones'] - if x['temperatureStatus']['isAvailable'] is True] - temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] - - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp + def is_away_mode_on(self) -> bool: + """Return True if away mode is on.""" + return self._status['systemModeStatus']['mode'] == EVO_AWAY @property def is_on(self) -> bool: @@ -499,9 +369,42 @@ class EvoController(EvoClimateDevice): return True @property - def is_away_mode_on(self) -> bool: - """Return True if away mode is on.""" - return self._status['systemModeStatus']['mode'] == EVO_AWAY + def min_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a minimum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 5 + + @property + def max_temp(self): + """Return the maximum target temperature of a evohome Controller. + + Although evohome Controllers do not have a maximum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 35 + + @property + def should_poll(self) -> bool: + """Return True as the evohome Controller should always be polled.""" + return True + + def _set_operation_mode(self, operation_mode): + try: + self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access + except (requests.exceptions.RequestException, + evohomeclient2.AuthenticationError) as err: + self._handle_exception(err) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode for the TCS. + + Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' + mode is needed, it can be enabled via turn_away_mode_on method. + """ + self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode)) def turn_away_mode_on(self): """Turn away mode on. @@ -519,27 +422,6 @@ class EvoController(EvoClimateDevice): """ self._set_operation_mode(EVO_AUTO) - def _set_operation_mode(self, operation_mode): - try: - import evohomeclient2 - self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access - except (requests.exceptions.RequestException, - evohomeclient2.AuthenticationError) as err: - self._handle_exception(err) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode for the TCS. - - Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' - mode is needed, it can be enabled via turn_away_mode_on method. - """ - self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode)) - - @property - def should_poll(self) -> bool: - """Return True as the evohome Controller should always be polled.""" - return True - def update(self): """Get the latest state data of the entire evohome Location. @@ -559,7 +441,6 @@ class EvoController(EvoClimateDevice): loc_idx = self._params[CONF_LOCATION_IDX] try: - import evohomeclient2 self._status.update( self._client.locations[loc_idx].status()[GWS][0][TCS][0]) except (requests.exceptions.RequestException, diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py new file mode 100644 index 00000000000..9fe1c49064f --- /dev/null +++ b/homeassistant/components/evohome/const.py @@ -0,0 +1,9 @@ +"""Provides the constants needed for evohome.""" + +DOMAIN = 'evohome' +DATA_EVOHOME = 'data_' + DOMAIN +DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN + +# These are used only to help prevent E501 (line too long) violations. +GWS = 'gateways' +TCS = 'temperatureControlSystems' diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index b612baa476a..33c1dd247b6 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "evohomeclient==0.3.2" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6f258b2d59c..7ef031a90cb 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -3,7 +3,7 @@ import asyncio import json import logging import os -from urllib.parse import urlparse +import pathlib from aiohttp import web import voluptuous as vol @@ -11,7 +11,6 @@ import jinja2 import homeassistant.helpers.config_validation as cv from homeassistant.components.http.view import HomeAssistantView -from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components import websocket_api from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED @@ -27,14 +26,13 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' -JS_DEFAULT_OPTION = 'auto' -JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', - 'description': 'Open-source home automation platform running on Python 3.', + 'description': + 'Home automation platform that puts local control and privacy first.', 'dir': 'ltr', 'display': 'standalone', 'icons': [], @@ -73,10 +71,9 @@ CONFIG_SCHEMA = vol.Schema({ }), vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EXTRA_HTML_URL_ES5): - vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION): - vol.In(JS_OPTIONS) + # We no longer use these options. + vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all, + vol.Optional(CONF_JS_VERSION): cv.match_all, }), }, extra=vol.ALLOW_EXTRA) @@ -191,6 +188,15 @@ def add_manifest_json_key(key, val): MANIFEST_JSON[key] = val +def _frontend_root(dev_repo_path): + """Return root path to the frontend files.""" + if dev_repo_path is not None: + return pathlib.Path(dev_repo_path) / 'hass_frontend' + + import hass_frontend + return hass_frontend.where() + + async def async_setup(hass, config): """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) @@ -207,39 +213,28 @@ async def async_setup(hass, config): repo_path = conf.get(CONF_FRONTEND_REPO) is_dev = repo_path is not None - hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) + root_path = _frontend_root(repo_path) - if is_dev: - hass_frontend_path = os.path.join(repo_path, 'hass_frontend') - hass_frontend_es5_path = os.path.join(repo_path, 'hass_frontend_es5') - else: - import hass_frontend - import hass_frontend_es5 - hass_frontend_path = hass_frontend.where() - hass_frontend_es5_path = hass_frontend_es5.where() + for path, should_cache in ( + ("service_worker.js", False), + ("robots.txt", False), + ("onboarding.html", True), + ("static", True), + ("frontend_latest", True), + ("frontend_es5", True), + ): + hass.http.register_static_path( + "/{}".format(path), str(root_path / path), should_cache) hass.http.register_static_path( - "/service_worker_es5.js", - os.path.join(hass_frontend_es5_path, "service_worker.js"), False) - hass.http.register_static_path( - "/service_worker.js", - os.path.join(hass_frontend_path, "service_worker.js"), False) - hass.http.register_static_path( - "/robots.txt", - os.path.join(hass_frontend_path, "robots.txt"), False) - hass.http.register_static_path("/static", hass_frontend_path, not is_dev) - hass.http.register_static_path( - "/frontend_latest", hass_frontend_path, not is_dev) - hass.http.register_static_path( - "/frontend_es5", hass_frontend_es5_path, not is_dev) + "/auth/authorize", str(root_path / "authorize.html"), False) local = hass.config.path('www') if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version) + index_view = IndexView(repo_path) hass.http.register_view(index_view) - hass.http.register_view(AuthorizeView(repo_path, js_version)) @callback def async_finalize_panel(panel): @@ -263,13 +258,9 @@ async def async_setup(hass, config): if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() - if DATA_EXTRA_HTML_URL_ES5 not in hass.data: - hass.data[DATA_EXTRA_HTML_URL_ES5] = set() for url in conf.get(CONF_EXTRA_HTML_URL, []): add_extra_html_url(hass, url, False) - for url in conf.get(CONF_EXTRA_HTML_URL_ES5, []): - add_extra_html_url(hass, url, True) _async_setup_themes(hass, conf.get(CONF_THEMES)) @@ -327,36 +318,6 @@ def _async_setup_themes(hass, themes): hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) -class AuthorizeView(HomeAssistantView): - """Serve the frontend.""" - - url = '/auth/authorize' - name = 'auth:authorize' - requires_auth = False - - def __init__(self, repo_path, js_option): - """Initialize the frontend view.""" - self.repo_path = repo_path - self.js_option = js_option - - async def get(self, request: web.Request): - """Redirect to the authorize page.""" - latest = self.repo_path is not None or \ - _is_latest(self.js_option, request) - - if latest: - base = 'frontend_latest' - else: - base = 'frontend_es5' - - location = "/{}/authorize.html{}".format( - base, str(request.url.relative())[15:]) - - return web.Response(status=302, headers={ - 'location': location - }) - - class IndexView(HomeAssistantView): """Serve the frontend.""" @@ -364,70 +325,48 @@ class IndexView(HomeAssistantView): name = 'frontend:index' requires_auth = False - def __init__(self, repo_path, js_option): + def __init__(self, repo_path): """Initialize the frontend view.""" self.repo_path = repo_path - self.js_option = js_option - self._template_cache = {} + self._template_cache = None - def get_template(self, latest): + def get_template(self): """Get template.""" - if self.repo_path is not None: - root = os.path.join(self.repo_path, 'hass_frontend') - elif latest: - import hass_frontend - root = hass_frontend.where() - else: - import hass_frontend_es5 - root = hass_frontend_es5.where() - - tpl = self._template_cache.get(root) - + tpl = self._template_cache if tpl is None: - with open(os.path.join(root, 'index.html')) as file: + with open( + str(_frontend_root(self.repo_path) / 'index.html') + ) as file: tpl = jinja2.Template(file.read()) # Cache template if not running from repository if self.repo_path is None: - self._template_cache[root] = tpl + self._template_cache = tpl return tpl async def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] - latest = self.repo_path is not None or \ - _is_latest(self.js_option, request) if not hass.components.onboarding.async_is_onboarded(): - if latest: - location = '/frontend_latest/onboarding.html' - else: - location = '/frontend_es5/onboarding.html' - return web.Response(status=302, headers={ - 'location': location + 'location': '/onboarding.html' }) - no_auth = '1' - if not request[KEY_AUTHENTICATED]: - # do not try to auto connect on load - no_auth = '0' + template = self._template_cache - template = await hass.async_add_job(self.get_template, latest) + if template is None: + template = await hass.async_add_executor_job(self.get_template) - extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 - - template_params = dict( - no_auth=no_auth, - theme_color=MANIFEST_JSON['theme_color'], - extra_urls=hass.data[extra_key], - use_oauth='1' + return web.Response( + text=template.render( + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL], + ), + content_type='text/html' ) - return web.Response(text=template.render(**template_params), - content_type='text/html') - class ManifestJSONView(HomeAssistantView): """View to return a manifest.json.""" @@ -443,38 +382,6 @@ class ManifestJSONView(HomeAssistantView): return web.Response(text=msg, content_type="application/manifest+json") -def _is_latest(js_option, request): - """ - Return whether we should serve latest untranspiled code. - - Set according to user's preference and URL override. - """ - import hass_frontend - - if request is None: - return js_option == 'latest' - - # latest in query - if 'latest' in request.query or ( - request.headers.get('Referer') and - 'latest' in urlparse(request.headers['Referer']).query): - return True - - # es5 in query - if 'es5' in request.query or ( - request.headers.get('Referer') and - 'es5' in urlparse(request.headers['Referer']).query): - return False - - # non-auto option in config - if js_option != 'auto': - return js_option == 'latest' - - useragent = request.headers.get('User-Agent') - - return useragent and hass_frontend.version(useragent) - - @callback def websocket_get_panels(hass, connection, msg): """Handle get panels command. diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0a82a36536f..45b1f0ff351 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190427.0" + "home-assistant-frontend==20190514.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index aa57af55852..181e61a7e48 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -57,4 +57,7 @@ async def async_setup(hass, hass_config): hass.async_create_task(async_load_platform( hass, 'climate', DOMAIN, {}, hass_config)) + hass.async_create_task(async_load_platform( + hass, 'water_heater', DOMAIN, {}, hass_config)) + return True diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index bc72b73c0ed..b396f8d6dac 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -1,4 +1,4 @@ -"""Supports Genius hub to provide climate controls.""" +"""Support for Genius Hub climate devices.""" import asyncio import logging @@ -13,6 +13,8 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) +GH_CLIMATE_DEVICES = ['radiator'] + GENIUSHUB_SUPPORT_FLAGS = \ SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_ON_OFF | \ @@ -21,7 +23,7 @@ GENIUSHUB_SUPPORT_FLAGS = \ GENIUSHUB_MAX_TEMP = 28.0 GENIUSHUB_MIN_TEMP = 4.0 -# Genius supports only Off, Override/Boost, Footprint & Timer modes +# Genius Hub Zones support only Off, Override/Boost, Footprint & Timer modes HA_OPMODE_TO_GH = { STATE_AUTO: 'timer', STATE_ECO: 'footprint', @@ -38,20 +40,20 @@ GH_STATE_TO_HA = { 'linked': None, 'other': None, } # intentionally missing 'off': None + +# temperature is repeated here, as it gives access to high-precision temps GH_DEVICE_STATE_ATTRS = ['temperature', 'type', 'occupied', 'override'] async def async_setup_platform(hass, hass_config, async_add_entities, discovery_info=None): - """Set up the Genius hub climate devices.""" + """Set up the Genius Hub climate entities.""" client = hass.data[DOMAIN]['client'] - zones = [] - for zone in client.hub.zone_objs: - if hasattr(zone, 'temperature'): - zones.append(GeniusClimate(client, zone)) + entities = [GeniusClimate(client, z) + for z in client.hub.zone_objs if z.type in GH_CLIMATE_DEVICES] - async_add_entities(zones) + async_add_entities(entities) class GeniusClimate(ClimateDevice): diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 78efeca7311..99449211a7d 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -3,8 +3,8 @@ "name": "Genius Hub", "documentation": "https://www.home-assistant.io/components/geniushub", "requirements": [ - "geniushub-client==0.4.5" + "geniushub-client==0.4.6" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py new file mode 100644 index 00000000000..f5f09f9b1d5 --- /dev/null +++ b/homeassistant/components/geniushub/water_heater.py @@ -0,0 +1,141 @@ +"""Support for Genius Hub water_heater devices.""" +import asyncio +import logging + +from homeassistant.components.water_heater import ( + WaterHeaterDevice, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) +from homeassistant.const import ( + ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS) + +from . import DOMAIN + +STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' + +_LOGGER = logging.getLogger(__name__) + +GH_WATER_HEATERS = ['hot water temperature'] + +GENIUSHUB_SUPPORT_FLAGS = \ + SUPPORT_TARGET_TEMPERATURE | \ + SUPPORT_OPERATION_MODE +# HA does not have SUPPORT_ON_OFF for water_heater + +GENIUSHUB_MAX_TEMP = 80.0 +GENIUSHUB_MIN_TEMP = 30.0 + +# Genius Hub HW supports only Off, Override/Boost & Timer modes +HA_OPMODE_TO_GH = { + STATE_OFF: 'off', + STATE_AUTO: 'timer', + STATE_MANUAL: 'override', +} +GH_OPMODE_OFF = 'off' +GH_STATE_TO_HA = { + 'off': STATE_OFF, + 'timer': STATE_AUTO, + 'footprint': None, + 'away': None, + 'override': STATE_MANUAL, + 'early': None, + 'test': None, + 'linked': None, + 'other': None, +} + +GH_DEVICE_STATE_ATTRS = ['type', 'override'] + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the Genius Hub water_heater entities.""" + client = hass.data[DOMAIN]['client'] + + entities = [GeniusWaterHeater(client, z) + for z in client.hub.zone_objs if z.type in GH_WATER_HEATERS] + + async_add_entities(entities) + + +class GeniusWaterHeater(WaterHeaterDevice): + """Representation of a Genius Hub water_heater device.""" + + def __init__(self, client, boiler): + """Initialize the water_heater device.""" + self._client = client + self._boiler = boiler + self._id = boiler.id + self._name = boiler.name + + self._operation_list = list(HA_OPMODE_TO_GH) + + @property + def name(self): + """Return the name of the water_heater device.""" + return self._boiler.name + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + tmp = self._boiler.__dict__.items() + state = {k: v for k, v in tmp if k in GH_DEVICE_STATE_ATTRS} + + return {'status': state} + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._boiler.temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._boiler.setpoint + + @property + def min_temp(self): + """Return max valid temperature that can be set.""" + return GENIUSHUB_MIN_TEMP + + @property + def max_temp(self): + """Return max valid temperature that can be set.""" + return GENIUSHUB_MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self): + """Return the list of supported features.""" + return GENIUSHUB_SUPPORT_FLAGS + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def current_operation(self): + """Return the current operation mode.""" + return GH_STATE_TO_HA.get(self._boiler.mode) + + async def async_set_operation_mode(self, operation_mode): + """Set a new operation mode for this boiler.""" + await self._boiler.set_mode(HA_OPMODE_TO_GH.get(operation_mode)) + + async def async_set_temperature(self, **kwargs): + """Set a new target temperature for this boiler.""" + temperature = kwargs[ATTR_TEMPERATURE] + await self._boiler.set_override(temperature, 3600) # 1 hour + + async def async_update(self): + """Get the latest data from the hub.""" + try: + await self._boiler.update() + except (AssertionError, asyncio.TimeoutError) as err: + _LOGGER.warning("Update for %s failed, message: %s", + self._id, err) diff --git a/homeassistant/components/geofency/.translations/cs.json b/homeassistant/components/geofency/.translations/cs.json new file mode 100644 index 00000000000..2fa1dfc9f4b --- /dev/null +++ b/homeassistant/components/geofency/.translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "create_entry": { + "default": "Chcete-li odes\u00edlat ud\u00e1losti do aplikace Home Assistant, mus\u00edte v aplikaci Geofency nastavit funkci webhook. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}` \n - Metoda: POST \n\n Dal\u0161\u00ed informace viz [dokumentace]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Geofency Webhook?", + "title": "Nastavit Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 0b4b757ce9e..57eaf5393ae 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -72,7 +72,7 @@ async def handle_webhook(hass, webhook_id, request): data = WEBHOOK_SCHEMA(dict(await request.post())) except vol.MultipleInvalid as error: return web.Response( - body=error.error_message, + text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 2a883e33da6..534b4c5cd59 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -175,9 +175,11 @@ class GlancesSensor(Entity): self._state = value['quicklook']['cpu'] elif self.type == 'cpu_temp': for sensor in value['sensors']: - if sensor['label'] in ['CPU', "Package id 0", - "Physical id 0", "cpu-thermal 1", - "exynos-therm 1", "soc_thermal 1"]: + if sensor['label'] in ['CPU', "CPU Temperature", + "Package id 0", "Physical id 0", + "cpu_thermal 1", "cpu-thermal 1", + "exynos-therm 1", "soc_thermal 1", + "soc-thermal 1"]: self._state = sensor['value'] elif self.type == 'docker_active': count = 0 diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 36ab3459d5c..993c24d8653 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -72,11 +72,11 @@ class GoogleCalendarData: service = self.calendar_service.get() except ServerNotFoundError: _LOGGER.warning("Unable to connect to Google, using cached data") - return False + return None, None params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params['calendarId'] = self.calendar_id if self.max_results: - params['max_results'] = self.max_results + params['maxResults'] = self.max_results if self.search: params['q'] = self.search @@ -84,12 +84,16 @@ class GoogleCalendarData: async def async_get_events(self, hass, start_date, end_date): """Get all events in a specific time frame.""" - service, params = await hass.async_add_job(self._prepare_query) + service, params = await hass.async_add_executor_job( + self._prepare_query) + if service is None: + return params['timeMin'] = start_date.isoformat('T') params['timeMax'] = end_date.isoformat('T') - events = await hass.async_add_job(service.events) - result = await hass.async_add_job(events.list(**params).execute) + events = await hass.async_add_executor_job(service.events) + result = await hass.async_add_executor_job( + events.list(**params).execute) items = result.get('items', []) event_list = [] @@ -106,6 +110,8 @@ class GoogleCalendarData: def update(self): """Get the latest data.""" service, params = self._prepare_query() + if service is None: + return False params['timeMin'] = dt.now().isoformat('T') events = service.events() diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 1bab27bdd12..0f15d10f181 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -51,6 +51,9 @@ TYPE_GARAGE = PREFIX_TYPES + 'GARAGE' TYPE_OUTLET = PREFIX_TYPES + 'OUTLET' TYPE_SENSOR = PREFIX_TYPES + 'SENSOR' TYPE_DOOR = PREFIX_TYPES + 'DOOR' +TYPE_TV = PREFIX_TYPES + 'TV' +TYPE_SPEAKER = PREFIX_TYPES + 'SPEAKER' +TYPE_MEDIA = PREFIX_TYPES + 'MEDIA' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' @@ -86,7 +89,7 @@ DOMAIN_TO_GOOGLE_TYPES = { input_boolean.DOMAIN: TYPE_SWITCH, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, - media_player.DOMAIN: TYPE_SWITCH, + media_player.DOMAIN: TYPE_MEDIA, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, switch.DOMAIN: TYPE_SWITCH, @@ -100,10 +103,12 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_DOOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR): - TYPE_SENSOR, + TYPE_GARAGE, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, + (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, + (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER, } CHALLENGE_ACK_NEEDED = 'ackNeeded' diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index bad186a4edb..cb2bf688ad0 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -60,6 +60,7 @@ TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' TRAIT_MODES = PREFIX_TRAITS + 'Modes' TRAIT_OPENCLOSE = PREFIX_TRAITS + 'OpenClose' +TRAIT_VOLUME = PREFIX_TRAITS + 'Volume' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -79,6 +80,8 @@ COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' COMMAND_MODES = PREFIX_COMMANDS + 'SetModes' COMMAND_OPENCLOSE = PREFIX_COMMANDS + 'OpenClose' +COMMAND_SET_VOLUME = PREFIX_COMMANDS + 'setVolume' +COMMAND_VOLUME_RELATIVE = PREFIX_COMMANDS + 'volumeRelative' TRAITS = [] @@ -141,8 +144,6 @@ class BrightnessTrait(_Trait): """Test if state is supported.""" if domain == light.DOMAIN: return features & light.SUPPORT_BRIGHTNESS - if domain == media_player.DOMAIN: - return features & media_player.SUPPORT_VOLUME_SET return False @@ -160,13 +161,6 @@ class BrightnessTrait(_Trait): if brightness is not None: response['brightness'] = int(100 * (brightness / 255)) - elif domain == media_player.DOMAIN: - level = self.state.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL) - if level is not None: - # Convert 0.0-1.0 to 0-255 - response['brightness'] = int(level * 100) - return response async def execute(self, command, data, params, challenge): @@ -179,13 +173,6 @@ class BrightnessTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params['brightness'] }, blocking=True, context=data.context) - elif domain == media_player.DOMAIN: - await self.hass.services.async_call( - media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { - ATTR_ENTITY_ID: self.state.entity_id, - media_player.ATTR_MEDIA_VOLUME_LEVEL: - params['brightness'] / 100 - }, blocking=True, context=data.context) @register_trait @@ -755,11 +742,10 @@ class LockUnlockTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" - _verify_pin_challenge(data, challenge) - if params['lock']: service = lock.SERVICE_LOCK else: + _verify_pin_challenge(data, challenge) service = lock.SERVICE_UNLOCK await self.hass.services.async_call(lock.DOMAIN, service, { @@ -1040,6 +1026,8 @@ class OpenCloseTrait(_Trait): COMMAND_OPENCLOSE ] + override_position = None + @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" @@ -1056,20 +1044,22 @@ class OpenCloseTrait(_Trait): def sync_attributes(self): """Return opening direction.""" - attrs = {} + response = {} if self.state.domain == binary_sensor.DOMAIN: - attrs['queryOnlyOpenClose'] = True - return attrs + response['queryOnlyOpenClose'] = True + return response def query_attributes(self): """Return state query attributes.""" domain = self.state.domain response = {} - if domain == cover.DOMAIN: - # When it's an assumed state, we will always report it as 50% - # Google will not issue an open command if the assumed state is - # open, even if that is currently incorrect. + if self.override_position is not None: + response['openPercent'] = self.override_position + + elif domain == cover.DOMAIN: + # When it's an assumed state, we will return that querying state + # is not supported. if self.state.attributes.get(ATTR_ASSUMED_STATE): raise SmartHomeError( ERR_NOT_SUPPORTED, @@ -1080,7 +1070,7 @@ class OpenCloseTrait(_Trait): ERR_NOT_SUPPORTED, 'Querying state is not supported') - position = self.state.attributes.get( + position = self.override_position or self.state.attributes.get( cover.ATTR_CURRENT_POSITION ) @@ -1104,33 +1094,113 @@ class OpenCloseTrait(_Trait): domain = self.state.domain if domain == cover.DOMAIN: - if self.state.attributes.get(ATTR_DEVICE_CLASS) in ( - cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE - ): - _verify_pin_challenge(data, challenge) + svc_params = {ATTR_ENTITY_ID: self.state.entity_id} - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) if params['openPercent'] == 0: - await self.hass.services.async_call( - cover.DOMAIN, cover.SERVICE_CLOSE_COVER, { - ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True, context=data.context) + service = cover.SERVICE_CLOSE_COVER + should_verify = False elif params['openPercent'] == 100: - await self.hass.services.async_call( - cover.DOMAIN, cover.SERVICE_OPEN_COVER, { - ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True, context=data.context) - elif position is not None: - await self.hass.services.async_call( - cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { - ATTR_ENTITY_ID: self.state.entity_id, - cover.ATTR_POSITION: params['openPercent'] - }, blocking=True, context=data.context) + service = cover.SERVICE_OPEN_COVER + should_verify = True + elif (self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & + cover.SUPPORT_SET_POSITION): + service = cover.SERVICE_SET_COVER_POSITION + should_verify = True + svc_params[cover.ATTR_POSITION] = params['openPercent'] else: raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, 'Setting a position is not supported') + if (should_verify and + self.state.attributes.get(ATTR_DEVICE_CLASS) + in (cover.DEVICE_CLASS_DOOR, + cover.DEVICE_CLASS_GARAGE)): + _verify_pin_challenge(data, challenge) + + await self.hass.services.async_call( + cover.DOMAIN, service, svc_params, + blocking=True, context=data.context) + + if (self.state.attributes.get(ATTR_ASSUMED_STATE) or + self.state.state == STATE_UNKNOWN): + self.override_position = params['openPercent'] + + +@register_trait +class VolumeTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/volume + """ + + name = TRAIT_VOLUME + commands = [ + COMMAND_SET_VOLUME, + COMMAND_VOLUME_RELATIVE, + ] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + response = {} + + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + muted = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_MUTED) + if level is not None: + # Convert 0.0-1.0 to 0-100 + response['currentVolume'] = int(level * 100) + response['isMuted'] = bool(muted) + + return response + + async def _execute_set_volume(self, data, params): + level = params['volumeLevel'] + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + level / 100 + }, blocking=True, context=data.context) + + async def _execute_volume_relative(self, data, params): + # This could also support up/down commands using relativeSteps + relative = params['volumeRelativeLevel'] + current = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + + await self.hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + current + relative / 100 + }, blocking=True, context=data.context) + + async def execute(self, command, data, params, challenge): + """Execute a brightness command.""" + if command == COMMAND_SET_VOLUME: + await self._execute_set_volume(data, params) + elif command == COMMAND_VOLUME_RELATIVE: + await self._execute_volume_relative(data, params) + else: + raise SmartHomeError( + ERR_NOT_SUPPORTED, 'Command not supported') + def _verify_pin_challenge(data, challenge): """Verify a pin challenge.""" diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index ddfa5c1504b..499d5351ad4 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -4,7 +4,7 @@ import logging from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT from homeassistant.helpers.entity import Entity -from ..greeneye_monitor import ( +from . import ( CONF_COUNTED_QUANTITY, CONF_COUNTED_QUANTITY_PER_PULSE, CONF_MONITOR_SERIAL_NUMBER, diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json index 01200a3aef9..dfa463fb148 100644 --- a/homeassistant/components/hangouts/.translations/es.json +++ b/homeassistant/components/hangouts/.translations/es.json @@ -24,7 +24,7 @@ "password": "Contrase\u00f1a" }, "description": "Vac\u00edo", - "title": "Usuario Google Hangouts" + "title": "Iniciar sesi\u00f3n en Google Hangouts" } }, "title": "Google Hangouts" diff --git a/homeassistant/components/hangouts/.translations/sv.json b/homeassistant/components/hangouts/.translations/sv.json index ae03fdbf722..993a191ef89 100644 --- a/homeassistant/components/hangouts/.translations/sv.json +++ b/homeassistant/components/hangouts/.translations/sv.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Auktoriseringskod (kr\u00e4vs vid manuell verifiering)", "email": "E-postadress", "password": "L\u00f6senord" }, diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json index a17bd76adb4..5d9bf3c7612 100644 --- a/homeassistant/components/hangouts/manifest.json +++ b/homeassistant/components/hangouts/manifest.json @@ -3,7 +3,7 @@ "name": "Hangouts", "documentation": "https://www.home-assistant.io/components/hangouts", "requirements": [ - "hangups==0.4.6" + "hangups==0.4.9" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/heos/.translations/ca.json b/homeassistant/components/heos/.translations/ca.json index 1d675ab1cd7..05d95116b10 100644 --- a/homeassistant/components/heos/.translations/ca.json +++ b/homeassistant/components/heos/.translations/ca.json @@ -16,6 +16,6 @@ "title": "Connexi\u00f3 amb Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/cs.json b/homeassistant/components/heos/.translations/cs.json new file mode 100644 index 00000000000..fac6458c5b8 --- /dev/null +++ b/homeassistant/components/heos/.translations/cs.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/es.json b/homeassistant/components/heos/.translations/es.json index beba4aea6f1..da5d5e0ab89 100644 --- a/homeassistant/components/heos/.translations/es.json +++ b/homeassistant/components/heos/.translations/es.json @@ -16,6 +16,6 @@ "title": "Conectar a Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/.translations/hu.json new file mode 100644 index 00000000000..8cd10b3c246 --- /dev/null +++ b/homeassistant/components/heos/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Kiszolg\u00e1l\u00f3", + "host": "Kiszolg\u00e1l\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/ko.json b/homeassistant/components/heos/.translations/ko.json index d02f48a3faf..9237800bf48 100644 --- a/homeassistant/components/heos/.translations/ko.json +++ b/homeassistant/components/heos/.translations/ko.json @@ -16,6 +16,6 @@ "title": "Heos \uc5f0\uacb0" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/no.json b/homeassistant/components/heos/.translations/no.json index f7595d80b96..144b08c0663 100644 --- a/homeassistant/components/heos/.translations/no.json +++ b/homeassistant/components/heos/.translations/no.json @@ -9,7 +9,8 @@ "step": { "user": { "data": { - "access_token": "Vert" + "access_token": "Vert", + "host": "Vert" }, "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til en Heos-enhet (helst en tilkoblet via kabel til nettverket).", "title": "Koble til Heos" diff --git a/homeassistant/components/heos/.translations/pl.json b/homeassistant/components/heos/.translations/pl.json index faa104d20ea..9b5f9844ddc 100644 --- a/homeassistant/components/heos/.translations/pl.json +++ b/homeassistant/components/heos/.translations/pl.json @@ -9,7 +9,8 @@ "step": { "user": { "data": { - "access_token": "Host" + "access_token": "Host", + "host": "Host" }, "description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia Heos (preferowane po\u0142\u0105czenie kablowe, nie WiFi).", "title": "Po\u0142\u0105cz si\u0119 z Heos" diff --git a/homeassistant/components/heos/.translations/sl.json b/homeassistant/components/heos/.translations/sl.json index 1e84381e7a6..2978d2bbbe6 100644 --- a/homeassistant/components/heos/.translations/sl.json +++ b/homeassistant/components/heos/.translations/sl.json @@ -9,12 +9,13 @@ "step": { "user": { "data": { - "access_token": "Gostitelj" + "access_token": "Gostitelj", + "host": "Gostitelj" }, "description": "Vnesite ime gostitelja ali naslov IP naprave Heos (po mo\u017enosti eno, ki je z omre\u017ejem povezana \u017ei\u010dno).", "title": "Pove\u017eite se z Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/sv.json b/homeassistant/components/heos/.translations/sv.json index 6e5d825ef26..d36ad203438 100644 --- a/homeassistant/components/heos/.translations/sv.json +++ b/homeassistant/components/heos/.translations/sv.json @@ -6,7 +6,8 @@ "step": { "user": { "data": { - "access_token": "V\u00e4rd" + "access_token": "V\u00e4rd", + "host": "V\u00e4rd" }, "description": "Ange v\u00e4rdnamnet eller IP-adressen f\u00f6r en Heos-enhet (helst en ansluten via kabel till n\u00e4tverket).", "title": "Anslut till Heos" diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 334c2572e74..6585393d12e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Dict import voluptuous as vol @@ -14,10 +15,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import Throttle +from . import services from .config_flow import format_title from .const import ( - COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER, - DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_SOURCES_UPDATED) + COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER, + DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_UPDATED) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -80,8 +82,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): if controller.is_signed_in: favorites = await controller.get_favorites() else: - _LOGGER.warning("%s is not logged in to your HEOS account and will" - " be unable to retrieve your favorites", host) + _LOGGER.warning( + "%s is not logged in to a HEOS account and will be unable " + "to retrieve HEOS favorites: Use the 'heos.sign_in' service " + "to sign-in to a HEOS account", host) inputs = await controller.get_input_sources() except (asyncio.TimeoutError, ConnectionError, CommandError) as error: await controller.disconnect() @@ -89,14 +93,20 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): exc_info=isinstance(error, CommandError)) raise ConfigEntryNotReady + controller_manager = ControllerManager(hass, controller) + await controller_manager.connect_listeners() + source_manager = SourceManager(favorites, inputs) source_manager.connect_update(hass, controller) hass.data[DOMAIN] = { - DATA_CONTROLLER: controller, + DATA_CONTROLLER_MANAGER: controller_manager, DATA_SOURCE_MANAGER: source_manager, MEDIA_PLAYER_DOMAIN: players } + + services.register(hass, controller) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, MEDIA_PLAYER_DOMAIN)) return True @@ -104,14 +114,94 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload a config entry.""" - controller = hass.data[DOMAIN][DATA_CONTROLLER] - controller.dispatcher.disconnect_all() - await controller.disconnect() + controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER] + await controller_manager.disconnect() hass.data.pop(DOMAIN) + + services.remove(hass) + return await hass.config_entries.async_forward_entry_unload( entry, MEDIA_PLAYER_DOMAIN) +class ControllerManager: + """Class that manages events of the controller.""" + + def __init__(self, hass, controller): + """Init the controller manager.""" + self._hass = hass + self._device_registry = None + self._entity_registry = None + self.controller = controller + self._signals = [] + + async def connect_listeners(self): + """Subscribe to events of interest.""" + from pyheos import const + self._device_registry, self._entity_registry = await asyncio.gather( + self._hass.helpers.device_registry.async_get_registry(), + self._hass.helpers.entity_registry.async_get_registry()) + # Handle controller events + self._signals.append(self.controller.dispatcher.connect( + const.SIGNAL_CONTROLLER_EVENT, self._controller_event)) + # Handle connection-related events + self._signals.append(self.controller.dispatcher.connect( + const.SIGNAL_HEOS_EVENT, self._heos_event)) + + async def disconnect(self): + """Disconnect subscriptions.""" + for signal_remove in self._signals: + signal_remove() + self._signals.clear() + self.controller.dispatcher.disconnect_all() + await self.controller.disconnect() + + async def _controller_event(self, event, data): + """Handle controller event.""" + from pyheos import const + if event == const.EVENT_PLAYERS_CHANGED: + self.update_ids(data[const.DATA_MAPPED_IDS]) + # Update players + self._hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_HEOS_UPDATED) + + async def _heos_event(self, event): + """Handle connection event.""" + from pyheos import CommandError, const + if event == const.EVENT_CONNECTED: + try: + # Retrieve latest players and refresh status + data = await self.controller.load_players() + self.update_ids(data[const.DATA_MAPPED_IDS]) + except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: + _LOGGER.error("Unable to refresh players: %s", ex) + # Update players + self._hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_HEOS_UPDATED) + + def update_ids(self, mapped_ids: Dict[int, int]): + """Update the IDs in the device and entity registry.""" + # mapped_ids contains the mapped IDs (new:old) + for new_id, old_id in mapped_ids.items(): + # update device registry + entry = self._device_registry.async_get_device( + {(DOMAIN, old_id)}, set()) + new_identifiers = {(DOMAIN, new_id)} + if entry: + self._device_registry.async_update_device( + entry.id, new_identifiers=new_identifiers) + _LOGGER.debug("Updated device %s identifiers to %s", + entry.id, new_identifiers) + # update entity registry + entity_id = self._entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id)) + if entity_id: + self._entity_registry.async_update_entity( + entity_id, new_unique_id=str(new_id)) + _LOGGER.debug("Updated entity %s unique id to %s", + entity_id, new_id) + + class SourceManager: """Class that manages sources for players.""" @@ -195,9 +285,10 @@ class SourceManager: exc_info=isinstance(error, CommandError)) return - async def update_sources(event, data): + async def update_sources(event, data=None): if event in (const.EVENT_SOURCES_CHANGED, - const.EVENT_USER_CHANGED): + const.EVENT_USER_CHANGED, + const.EVENT_CONNECTED): sources = await get_sources() # If throttled, it will return None if sources: @@ -206,7 +297,9 @@ class SourceManager: _LOGGER.debug("Sources updated due to changed event") # Let players know to update hass.helpers.dispatcher.async_dispatcher_send( - SIGNAL_HEOS_SOURCES_UPDATED) + SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( const.SIGNAL_CONTROLLER_EVENT, update_sources) + controller.dispatcher.connect( + const.SIGNAL_HEOS_EVENT, update_sources) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index fc3a7fd8f30..d3e3ccb07c3 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -1,9 +1,13 @@ """Const for the HEOS integration.""" +ATTR_PASSWORD = "password" +ATTR_USERNAME = "username" COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 -DATA_CONTROLLER = "controller" +DATA_CONTROLLER_MANAGER = "controller" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = 'heos' -SIGNAL_HEOS_SOURCES_UPDATED = "heos_sources_updated" +SERVICE_SIGN_IN = "sign_in" +SERVICE_SIGN_OUT = "sign_out" +SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 3faa346988c..f3a2ff4eccf 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -1,9 +1,9 @@ { "domain": "heos", - "name": "Heos", + "name": "HEOS", "documentation": "https://www.home-assistant.io/components/heos", "requirements": [ - "pyheos==0.5.1" + "pyheos==0.5.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 739667f5834..00a3b721efb 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -7,21 +7,23 @@ from typing import Sequence from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( - DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, - SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP) + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_URL, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.dt import utcnow from .const import ( - DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_SOURCES_UPDATED) + DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED) BASE_SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_STEP | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE + SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | \ + SUPPORT_PLAY_MEDIA _LOGGER = logging.getLogger(__name__) @@ -48,7 +50,8 @@ def log_command_error(command: str): from pyheos import CommandError try: await func(*args, **kwargs) - except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: + except (CommandError, asyncio.TimeoutError, ConnectionError, + ValueError) as ex: _LOGGER.error("Unable to %s: %s", command, ex) return wrapper return decorator @@ -78,23 +81,6 @@ class HeosMediaPlayer(MediaPlayerDevice): const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK } - async def _controller_event(self, event, data): - """Handle controller event.""" - from pyheos import const - if event == const.EVENT_PLAYERS_CHANGED: - await self.async_update_ha_state(True) - - async def _heos_event(self, event): - """Handle connection event.""" - from pyheos import CommandError, const - if event == const.EVENT_CONNECTED: - try: - await self._player.refresh() - except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: - _LOGGER.error("Unable to refresh player %s: %s", - self._player, ex) - await self.async_update_ha_state(True) - async def _player_update(self, player_id, event): """Handle player attribute updated.""" from pyheos import const @@ -104,7 +90,7 @@ class HeosMediaPlayer(MediaPlayerDevice): self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) - async def _sources_updated(self): + async def _heos_updated(self): """Handle sources changed.""" await self.async_update_ha_state(True) @@ -115,16 +101,10 @@ class HeosMediaPlayer(MediaPlayerDevice): # Update state when attributes of the player change self._signals.append(self._player.heos.dispatcher.connect( const.SIGNAL_PLAYER_EVENT, self._player_update)) - # Update state when available players change - self._signals.append(self._player.heos.dispatcher.connect( - const.SIGNAL_CONTROLLER_EVENT, self._controller_event)) - # Update state upon connect/disconnects - self._signals.append(self._player.heos.dispatcher.connect( - const.SIGNAL_HEOS_EVENT, self._heos_event)) - # Update state when sources change + # Update state when heos changes self._signals.append( self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_HEOS_SOURCES_UPDATED, self._sources_updated)) + SIGNAL_HEOS_UPDATED, self._heos_updated)) @log_command_error("clear playlist") async def async_clear_playlist(self): @@ -161,6 +141,55 @@ class HeosMediaPlayer(MediaPlayerDevice): """Mute the volume.""" await self._player.set_mute(mute) + @log_command_error("play media") + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + if media_type == MEDIA_TYPE_URL: + await self._player.play_url(media_id) + return + + if media_type == "quick_select": + # media_id may be an int or a str + selects = await self._player.get_quick_selects() + try: + index = int(media_id) + except ValueError: + # Try finding index by name + index = next((index for index, select in selects.items() + if select == media_id), None) + if index is None: + raise ValueError("Invalid quick select '{}'".format(media_id)) + await self._player.play_quick_select(index) + return + + if media_type == MEDIA_TYPE_PLAYLIST: + from pyheos import const + playlists = await self._player.heos.get_playlists() + playlist = next((p for p in playlists if p.name == media_id), None) + if not playlist: + raise ValueError("Invalid playlist '{}'".format(media_id)) + add_queue_option = const.ADD_QUEUE_ADD_TO_END \ + if kwargs.get(ATTR_MEDIA_ENQUEUE) \ + else const.ADD_QUEUE_REPLACE_AND_PLAY + await self._player.add_to_queue(playlist, add_queue_option) + return + + if media_type == "favorite": + # media_id may be an int or str + try: + index = int(media_id) + except ValueError: + # Try finding index by name + index = next((index for index, favorite + in self._source_manager.favorites.items() + if favorite.name == media_id), None) + if index is None: + raise ValueError("Invalid favorite '{}'".format(media_id)) + await self._player.play_favorite(index) + return + + raise ValueError("Unsupported media type '{}'".format(media_type)) + @log_command_error("select source") async def async_select_source(self, source): """Select input source.""" @@ -200,7 +229,7 @@ class HeosMediaPlayer(MediaPlayerDevice): """Get attributes about the device.""" return { 'identifiers': { - (DOMAIN, self._player.player_id) + (HEOS_DOMAIN, self._player.player_id) }, 'name': self._player.name, 'model': self._player.model, @@ -268,6 +297,11 @@ class HeosMediaPlayer(MediaPlayerDevice): return None return self._media_position_updated_at + @property + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return True + @property def media_image_url(self) -> str: """Image url of current playing media.""" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py new file mode 100644 index 00000000000..5b998f384dc --- /dev/null +++ b/homeassistant/components/heos/services.py @@ -0,0 +1,66 @@ +"""Services for the HEOS integration.""" +import asyncio +import functools +import logging + +from pyheos import CommandError, Heos, const +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_PASSWORD, ATTR_USERNAME, DOMAIN, SERVICE_SIGN_IN, SERVICE_SIGN_OUT) + +_LOGGER = logging.getLogger(__name__) + +HEOS_SIGN_IN_SCHEMA = vol.Schema({ + vol.Required(ATTR_USERNAME): cv.string, + vol.Required(ATTR_PASSWORD): cv.string +}) + +HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) + + +def register(hass: HomeAssistantType, controller: Heos): + """Register HEOS services.""" + hass.services.async_register( + DOMAIN, SERVICE_SIGN_IN, + functools.partial(_sign_in_handler, controller), + schema=HEOS_SIGN_IN_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_SIGN_OUT, + functools.partial(_sign_out_handler, controller), + schema=HEOS_SIGN_OUT_SCHEMA) + + +def remove(hass: HomeAssistantType): + """Unregister HEOS services.""" + hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN) + hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT) + + +async def _sign_in_handler(controller, service): + """Sign in to the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign in because HEOS is not connected") + return + username = service.data[ATTR_USERNAME] + password = service.data[ATTR_PASSWORD] + try: + await controller.sign_in(username, password) + except CommandError as err: + _LOGGER.error("Sign in failed: %s", err) + except (asyncio.TimeoutError, ConnectionError) as err: + _LOGGER.error("Unable to sign in: %s", err) + + +async def _sign_out_handler(controller, service): + """Sign out of the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign out because HEOS is not connected") + return + try: + await controller.sign_out() + except (asyncio.TimeoutError, ConnectionError, CommandError) as err: + _LOGGER.error("Unable to sign out: %s", err) diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml new file mode 100644 index 00000000000..8274240368f --- /dev/null +++ b/homeassistant/components/heos/services.yaml @@ -0,0 +1,12 @@ +sign_in: + description: Sign the controller in to a HEOS account. + fields: + username: + description: The username or email of the HEOS account. [Required] + example: 'example@example.com' + password: + description: The password of the HEOS account. [Required] + example: 'password' + +sign_out: + description: Sign the controller out of the HEOS account. \ No newline at end of file diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index f524455fede..a37b085c0dc 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -6,6 +6,7 @@ from zlib import adler32 import voluptuous as vol from homeassistant.components import cover +from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY, @@ -99,7 +100,7 @@ async def async_setup(hass, config): def get_accessory(hass, driver, state, aid, config): """Take state and return an accessory object if supported.""" if not aid: - _LOGGER.warning('The entitiy "%s" is not supported, since it ' + _LOGGER.warning('The entity "%s" is not supported, since it ' 'generates an invalid aid, please change it.', state.entity_id) return None @@ -110,7 +111,7 @@ def get_accessory(hass, driver, state, aid, config): if state.domain == 'alarm_control_panel': a_type = 'SecuritySystem' - elif state.domain == 'binary_sensor' or state.domain == 'device_tracker': + elif state.domain in ('binary_sensor', 'device_tracker', 'person'): a_type = 'BinarySensor' elif state.domain == 'climate': @@ -138,10 +139,15 @@ def get_accessory(hass, driver, state, aid, config): a_type = 'Lock' elif state.domain == 'media_player': + device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST) - if feature_list and \ - validate_media_player_features(state, feature_list): - a_type = 'MediaPlayer' + + if device_class == DEVICE_CLASS_TV: + a_type = 'TelevisionMediaPlayer' + else: + if feature_list and \ + validate_media_player_features(state, feature_list): + a_type = 'MediaPlayer' elif state.domain == 'sensor': device_class = state.attributes.get(ATTR_DEVICE_CLASS) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8b0e70f616e..13dfc90841f 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -19,8 +19,9 @@ from homeassistant.util import dt as dt_util from .const import ( ATTR_DISPLAY_NAME, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, CHAR_BATTERY_LEVEL, CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, - CONF_LINKED_BATTERY_SENSOR, DEBOUNCE_TIMEOUT, EVENT_HOMEKIT_CHANGED, - MANUFACTURER, SERV_BATTERY_SERVICE) + CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEBOUNCE_TIMEOUT, + DEFAULT_LOW_BATTERY_THRESHOLD, EVENT_HOMEKIT_CHANGED, MANUFACTURER, + SERV_BATTERY_SERVICE) from .util import convert_to_float, dismiss_setup_message, show_setup_message _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,9 @@ class HomeAccessory(Accessory): self._support_battery_charging = True self.linked_battery_sensor = \ self.config.get(CONF_LINKED_BATTERY_SENSOR) + self.low_battery_threshold = \ + self.config.get(CONF_LOW_BATTERY_THRESHOLD, + DEFAULT_LOW_BATTERY_THRESHOLD) """Add battery service if available""" battery_found = self.hass.states.get(self.entity_id).attributes \ @@ -147,7 +151,8 @@ class HomeAccessory(Accessory): if battery_level is None: return self._char_battery.set_value(battery_level) - self._char_low_battery.set_value(battery_level < 20) + self._char_low_battery.set_value( + battery_level < self.low_battery_threshold) _LOGGER.debug('%s: Updated battery level to %d', self.entity_id, battery_level) if not self._support_battery_charging: diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 0a2b7a0fd5d..11c0314abf2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -16,10 +16,12 @@ CONF_FEATURE = 'feature' CONF_FEATURE_LIST = 'feature_list' CONF_FILTER = 'filter' CONF_LINKED_BATTERY_SENSOR = 'linked_battery_sensor' +CONF_LOW_BATTERY_THRESHOLD = 'low_battery_threshold' CONF_SAFE_MODE = 'safe_mode' # #### Config Defaults #### DEFAULT_AUTO_START = True +DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_PORT = 51827 DEFAULT_SAFE_MODE = False @@ -59,6 +61,7 @@ SERV_CONTACT_SENSOR = 'ContactSensor' SERV_FANV2 = 'Fanv2' SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' SERV_HUMIDITY_SENSOR = 'HumiditySensor' +SERV_INPUT_SOURCE = 'InputSource' SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHT_SENSOR = 'LightSensor' SERV_LIGHTBULB = 'Lightbulb' @@ -69,6 +72,8 @@ SERV_OUTLET = 'Outlet' SERV_SECURITY_SYSTEM = 'SecuritySystem' SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' +SERV_TELEVISION = 'Television' +SERV_TELEVISION_SPEAKER = 'TelevisionSpeaker' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' SERV_VALVE = 'Valve' @@ -76,6 +81,7 @@ SERV_WINDOW_COVERING = 'WindowCovering' # #### Characteristics #### CHAR_ACTIVE = 'Active' +CHAR_ACTIVE_IDENTIFIER = 'ActiveIdentifier' CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' CHAR_AIR_QUALITY = 'AirQuality' CHAR_BATTERY_LEVEL = 'BatteryLevel' @@ -88,6 +94,7 @@ CHAR_CARBON_MONOXIDE_LEVEL = 'CarbonMonoxideLevel' CHAR_CARBON_MONOXIDE_PEAK_LEVEL = 'CarbonMonoxidePeakLevel' CHAR_CHARGING_STATE = 'ChargingState' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' +CHAR_CONFIGURED_NAME = 'ConfiguredName' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' @@ -97,10 +104,14 @@ CHAR_CURRENT_POSITION = 'CurrentPosition' CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_CURRENT_VISIBILITY_STATE = 'CurrentVisibilityState' CHAR_FIRMWARE_REVISION = 'FirmwareRevision' CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_HUE = 'Hue' +CHAR_IDENTIFIER = 'Identifier' CHAR_IN_USE = 'InUse' +CHAR_INPUT_SOURCE_TYPE = 'InputSourceType' +CHAR_IS_CONFIGURED = 'IsConfigured' CHAR_LEAK_DETECTED = 'LeakDetected' CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' CHAR_LOCK_TARGET_STATE = 'LockTargetState' @@ -108,15 +119,18 @@ CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' CHAR_MOTION_DETECTED = 'MotionDetected' +CHAR_MUTE = 'Mute' CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' CHAR_OUTLET_IN_USE = 'OutletInUse' CHAR_POSITION_STATE = 'PositionState' +CHAR_REMOTE_KEY = 'RemoteKey' CHAR_ROTATION_DIRECTION = 'RotationDirection' CHAR_ROTATION_SPEED = 'RotationSpeed' CHAR_SATURATION = 'Saturation' CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_SLEEP_DISCOVER_MODE = 'SleepDiscoveryMode' CHAR_SMOKE_DETECTED = 'SmokeDetected' CHAR_STATUS_LOW_BATTERY = 'StatusLowBattery' CHAR_SWING_MODE = 'SwingMode' @@ -127,6 +141,10 @@ CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' CHAR_TARGET_TEMPERATURE = 'TargetTemperature' CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' CHAR_VALVE_TYPE = 'ValveType' +CHAR_VOLUME = 'Volume' +CHAR_VOLUME_SELECTOR = 'VolumeSelector' +CHAR_VOLUME_CONTROL_TYPE = 'VolumeControlType' + # #### Properties #### PROP_MAX_VALUE = 'maxValue' diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index f8f4ef96992..b0c4be35e1b 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,23 +1,49 @@ """Class to hold all media player accessories.""" import logging -from pyhap.const import CATEGORY_SWITCH +from pyhap.const import CATEGORY_SWITCH, CATEGORY_TELEVISION from homeassistant.components.media_player import ( - ATTR_MEDIA_VOLUME_MUTED, DOMAIN) + ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_MUTED, + ATTR_MEDIA_VOLUME_LEVEL, SERVICE_SELECT_SOURCE, DOMAIN, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + SUPPORT_SELECT_SOURCE) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, - STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, + SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, STATE_OFF, STATE_PLAYING, + STATE_PAUSED, STATE_UNKNOWN) from . import TYPES from .accessories import HomeAccessory from .const import ( - CHAR_NAME, CHAR_ON, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH) + CHAR_ACTIVE, CHAR_ACTIVE_IDENTIFIER, CHAR_CONFIGURED_NAME, + CHAR_CURRENT_VISIBILITY_STATE, CHAR_IDENTIFIER, CHAR_INPUT_SOURCE_TYPE, + CHAR_IS_CONFIGURED, CHAR_NAME, CHAR_SLEEP_DISCOVER_MODE, CHAR_MUTE, + CHAR_ON, CHAR_REMOTE_KEY, CHAR_VOLUME_CONTROL_TYPE, CHAR_VOLUME_SELECTOR, + CHAR_VOLUME, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH, SERV_TELEVISION, + SERV_TELEVISION_SPEAKER, SERV_INPUT_SOURCE) _LOGGER = logging.getLogger(__name__) +MEDIA_PLAYER_KEYS = { + # 0: "Rewind", + # 1: "FastForward", + # 2: "NextTrack", + # 3: "PreviousTrack", + # 4: "ArrowUp", + # 5: "ArrowDown", + # 6: "ArrowLeft", + # 7: "ArrowRight", + # 8: "Select", + # 9: "Back", + # 10: "Exit", + 11: SERVICE_MEDIA_PLAY_PAUSE, + # 15: "Information", +} + MODE_FRIENDLY_NAME = { FEATURE_ON_OFF: 'Power', FEATURE_PLAY_PAUSE: 'Play/Pause', @@ -142,3 +168,185 @@ class MediaPlayer(HomeAccessory): self.entity_id, current_state) self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) self._flag[FEATURE_TOGGLE_MUTE] = False + + +@TYPES.register('TelevisionMediaPlayer') +class TelevisionMediaPlayer(HomeAccessory): + """Generate a Television Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_TELEVISION) + + self._flag = {CHAR_ACTIVE: False, CHAR_ACTIVE_IDENTIFIER: False, + CHAR_MUTE: False} + self.support_select_source = False + + self.sources = [] + + # Add additional characteristics if volume or input selection supported + self.chars_tv = [] + self.chars_speaker = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if features & (SUPPORT_PLAY | SUPPORT_PAUSE): + self.chars_tv.append(CHAR_REMOTE_KEY) + if features & SUPPORT_VOLUME_MUTE or features & SUPPORT_VOLUME_STEP: + self.chars_speaker.extend((CHAR_NAME, CHAR_ACTIVE, + CHAR_VOLUME_CONTROL_TYPE, + CHAR_VOLUME_SELECTOR)) + if features & SUPPORT_VOLUME_SET: + self.chars_speaker.append(CHAR_VOLUME) + + if features & SUPPORT_SELECT_SOURCE: + self.support_select_source = True + + serv_tv = self.add_preload_service(SERV_TELEVISION, self.chars_tv) + self.set_primary_service(serv_tv) + serv_tv.configure_char(CHAR_CONFIGURED_NAME, value=self.display_name) + serv_tv.configure_char(CHAR_SLEEP_DISCOVER_MODE, value=True) + self.char_active = serv_tv.configure_char( + CHAR_ACTIVE, setter_callback=self.set_on_off) + + if CHAR_REMOTE_KEY in self.chars_tv: + self.char_remote_key = serv_tv.configure_char( + CHAR_REMOTE_KEY, setter_callback=self.set_remote_key) + + if CHAR_VOLUME_SELECTOR in self.chars_speaker: + serv_speaker = self.add_preload_service( + SERV_TELEVISION_SPEAKER, self.chars_speaker) + serv_tv.add_linked_service(serv_speaker) + + name = '{} {}'.format(self.display_name, 'Volume') + serv_speaker.configure_char(CHAR_NAME, value=name) + serv_speaker.configure_char(CHAR_ACTIVE, value=1) + + self.char_mute = serv_speaker.configure_char( + CHAR_MUTE, value=False, setter_callback=self.set_mute) + + volume_control_type = 1 if CHAR_VOLUME in self.chars_speaker else 2 + serv_speaker.configure_char(CHAR_VOLUME_CONTROL_TYPE, + value=volume_control_type) + + self.char_volume_selector = serv_speaker.configure_char( + CHAR_VOLUME_SELECTOR, setter_callback=self.set_volume_step) + + if CHAR_VOLUME in self.chars_speaker: + self.char_volume = serv_speaker.configure_char( + CHAR_VOLUME, setter_callback=self.set_volume) + + if self.support_select_source: + self.sources = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_INPUT_SOURCE_LIST, []) + self.char_input_source = serv_tv.configure_char( + CHAR_ACTIVE_IDENTIFIER, setter_callback=self.set_input_source) + for index, source in enumerate(self.sources): + serv_input = self.add_preload_service( + SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME]) + serv_tv.add_linked_service(serv_input) + serv_input.configure_char( + CHAR_CONFIGURED_NAME, value=source) + serv_input.configure_char(CHAR_NAME, value=source) + serv_input.configure_char(CHAR_IDENTIFIER, value=index) + serv_input.configure_char(CHAR_IS_CONFIGURED, value=True) + input_type = 3 if "hdmi" in source.lower() else 0 + serv_input.configure_char(CHAR_INPUT_SOURCE_TYPE, + value=input_type) + serv_input.configure_char(CHAR_CURRENT_VISIBILITY_STATE, + value=False) + _LOGGER.debug('%s: Added source %s.', self.entity_id, source) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', + self.entity_id, value) + self._flag[CHAR_ACTIVE] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + def set_mute(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s', + self.entity_id, value) + self._flag[CHAR_MUTE] = True + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_MEDIA_VOLUME_MUTED: value} + self.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) + + 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) + + 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] + self._flag[CHAR_ACTIVE_IDENTIFIER] = True + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_INPUT_SOURCE: source} + self.call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) + + def set_remote_key(self, value): + """Send remote key value if call came from HomeKit.""" + _LOGGER.debug('%s: Set remote key to %s', self.entity_id, value) + service = MEDIA_PLAYER_KEYS.get(value) + if service: + # Handle Play Pause + if service == SERVICE_MEDIA_PLAY_PAUSE: + state = self.hass.states.get(self.entity_id).state + if state in (STATE_PLAYING, STATE_PAUSED): + service = SERVICE_MEDIA_PLAY if state == STATE_PAUSED \ + else SERVICE_MEDIA_PAUSE + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + def update_state(self, new_state): + """Update Television state after state changed.""" + current_state = new_state.state + + # Power state television + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN) + if not self._flag[CHAR_ACTIVE]: + _LOGGER.debug('%s: Set current active state to %s', + self.entity_id, hk_state) + self.char_active.set_value(hk_state) + self._flag[CHAR_ACTIVE] = False + + # Set mute state + if CHAR_VOLUME_SELECTOR in self.chars_speaker: + current_mute_state = new_state.attributes.get( + ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[CHAR_MUTE]: + _LOGGER.debug('%s: Set current mute state to %s', + self.entity_id, current_mute_state) + self.char_mute.set_value(current_mute_state) + self._flag[CHAR_MUTE] = False + + # Set active input + if self.support_select_source: + source_name = new_state.attributes.get(ATTR_INPUT_SOURCE) + if self.sources and not self._flag[CHAR_ACTIVE_IDENTIFIER]: + _LOGGER.debug('%s: Set current input to %s', self.entity_id, + source_name) + if source_name in self.sources: + index = self.sources.index(source_name) + self.char_input_source.set_value(index) + else: + _LOGGER.warning('%s: Sources out of sync. ' + 'Restart HomeAssistant', self.entity_id) + self.char_input_source.set_value(0) + self._flag[CHAR_ACTIVE_IDENTIFIER] = False diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 1bf57f1b1f9..b3c90ae6cbe 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -13,7 +13,8 @@ import homeassistant.util.temperature as temp_util from .const import ( CONF_FEATURE, CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR, - FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, + CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) @@ -23,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) BASIC_INFO_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LOW_BATTERY_THRESHOLD, + default=DEFAULT_LOW_BATTERY_THRESHOLD): cv.positive_int, }) FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend({ diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json index e53fb5f9def..8765a859418 100644 --- a/homeassistant/components/homekit_controller/.translations/ca.json +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "No s'ha pogut vincular, no s'ha trobat el dispositiu.", "already_configured": "Accessori ja configurat amb aquest controlador.", "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.", "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.", @@ -9,16 +10,21 @@ }, "error": { "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.", + "busy_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 actualment ho est\u00e0 intentant amb un altre controlador diferent.", + "max_peers_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 no t\u00e9 suficient espai lliure.", + "max_tries_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 ha rebut m\u00e9s de 100 intents d\u2019autenticaci\u00f3 fallits.", + "pairing_failed": "S'ha produ\u00eft un error mentre s'intentava la vinculaci\u00f3 amb el dispositiu. Pot ser que sigui un error temporal o pot ser que el teu dispositiu encara no estigui suportat.", "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.", "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida." }, + "flow_title": "Accessori HomeKit: {name}", "step": { "pair": { "data": { "pairing_code": "Codi de vinculaci\u00f3" }, - "description": "Introdueix el codi de vinculaci\u00f3 HomeKit per utilitzar aquest accessori", - "title": "Vinculaci\u00f3 amb {{ model }}" + "description": "Introdueix el codi de vinculaci\u00f3 de HomeKit per utilitzar aquest accessori (format XXX-XX-XXX)", + "title": "Vinculaci\u00f3 amb" }, "user": { "data": { @@ -28,6 +34,6 @@ "title": "Vinculaci\u00f3 amb un accessori HomeKit" } }, - "title": "Acessori HomeKit" + "title": "Accessori HomeKit" } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/cs.json b/homeassistant/components/homekit_controller/.translations/cs.json new file mode 100644 index 00000000000..e70ed3973d2 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "P\u00e1rov\u00e1n\u00ed nelze p\u0159idat, proto\u017ee za\u0159\u00edzen\u00ed ji\u017e nelze nal\u00e9zt." + }, + "error": { + "busy_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee je ji\u017e sp\u00e1rov\u00e1no s jin\u00fdm \u0159adi\u010dem.", + "max_peers_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee nem\u00e1 voln\u00e9 \u00falo\u017ei\u0161t\u011b pro p\u00e1rov\u00e1n\u00ed.", + "max_tries_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee p\u0159ijalo v\u00edce ne\u017e 100 ne\u00fasp\u011b\u0161n\u00fdch pokus\u016f o ov\u011b\u0159en\u00ed.", + "pairing_failed": "P\u0159i pokusu o sp\u00e1rov\u00e1n\u00ed s t\u00edmto za\u0159\u00edzen\u00edm do\u0161lo k neo\u0161et\u0159en\u00e9 chyb\u011b. M\u016f\u017ee se jednat o do\u010dasn\u00e9 selh\u00e1n\u00ed nebo za\u0159\u00edzen\u00ed nen\u00ed aktu\u00e1ln\u011b podporov\u00e1no." + }, + "step": { + "pair": { + "description": "Chcete-li pou\u017e\u00edt toto p\u0159\u00edslu\u0161enstv\u00ed, zadejte k\u00f3d p\u00e1rov\u00e1n\u00ed HomeKit (ve form\u00e1tu XXX-XX-XXX)", + "title": "P\u00e1rov\u00e1n\u00ed s dopl\u0148kem HomeKit" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json index 126bb0362a8..d13d2bb7e2a 100644 --- a/homeassistant/components/homekit_controller/.translations/de.json +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Die Kopplung kann nicht durchgef\u00fchrt werden, da das Ger\u00e4t nicht mehr gefunden werden kann.", "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.", "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.", "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.", @@ -9,9 +10,14 @@ }, "error": { "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.", + "busy_error": "Das Ger\u00e4t weigerte sich, das Kopplung durchzuf\u00fchren, da es bereits mit einem anderen Controller gekoppelt ist.", + "max_peers_error": "Das Ger\u00e4t weigerte sich, die Kopplung durchzuf\u00fchren, da es keinen freien Kopplungs-Speicher hat.", + "max_tries_error": "Das Ger\u00e4t hat sich geweigert die Kopplung durchzuf\u00fchren, da es mehr als 100 erfolglose Authentifizierungsversuche erhalten hat.", + "pairing_failed": "Beim Versuch dieses Ger\u00e4t zu koppeln ist ein Fehler aufgetreten. Dies kann ein vor\u00fcbergehender Fehler sein oder das Ger\u00e4t wird derzeit m\u00f6glicherweise nicht unterst\u00fctzt.", "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut", "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen." }, + "flow_title": "HomeKit-Zubeh\u00f6r: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 591e035ed18..059f0f7afe7 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Cannot add pairing as device can no longer be found.", "already_configured": "Accessory is already configured with this controller.", "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", @@ -9,15 +10,20 @@ }, "error": { "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "busy_error": "Device refused to add pairing as it is already pairing with another controller.", + "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", + "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", + "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", "unable_to_pair": "Unable to pair, please try again.", "unknown_error": "Device reported an unknown error. Pairing failed." }, + "flow_title": "HomeKit Accessory: {name}", "step": { "pair": { "data": { "pairing_code": "Pairing Code" }, - "description": "Enter your HomeKit pairing code to use this accessory", + "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory", "title": "Pair with HomeKit Accessory" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json index 1b1edbd5146..f22b4158698 100644 --- a/homeassistant/components/homekit_controller/.translations/es.json +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -4,28 +4,28 @@ "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", - "invalid_config_entry": "Este dispositivo se muestra como listo para emparejar pero ya existe una entrada de configuraci\u00f3n conflictiva en Home Assistant que debe ser eliminada primero.", + "invalid_config_entry": "Este dispositivo se muestra como listo para vincular, pero ya existe una entrada que causa conflicto en Home Assistant y se debe eliminar primero.", "no_devices": "No se encontraron dispositivos no emparejados" }, "error": { "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", - "unknown_error": "El dispositivo report\u00f3 un error desconocido. El emparejamiento ha fallado." + "unknown_error": "El dispositivo report\u00f3 un error desconocido. La vinculaci\u00f3n ha fallado." }, "step": { "pair": { "data": { - "pairing_code": "C\u00f3digo de Emparejamiento" + "pairing_code": "C\u00f3digo de vinculaci\u00f3n" }, - "description": "Introduce tu c\u00f3digo de emparejamiento HomeKit para usar este accesorio", - "title": "Emparejar con accesorio HomeKit" + "description": "Introduce tu c\u00f3digo de vinculaci\u00f3n de HomeKit para usar este accesorio", + "title": "Vincular con accesorio HomeKit" }, "user": { "data": { "device": "Dispositivo" }, - "description": "Selecciona el dispositivo que desea emparejar", - "title": "Emparejar con accesorio HomeKit" + "description": "Selecciona el dispositivo que quieres vincular", + "title": "Vincular con accesorio HomeKit" } }, "title": "Accesorio HomeKit" diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index b512ad67732..c780f07e96e 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -1,6 +1,7 @@ { "config": { "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_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.", @@ -9,15 +10,20 @@ }, "error": { "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "busy_error": "\uae30\uae30\uac00 \uc774\ubbf8 \ub2e4\ub978 \ucee8\ud2b8\ub864\ub7ec\uc640 \ud398\uc5b4\ub9c1 \uc911\uc774\ubbc0\ub85c \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "max_peers_error": "\uae30\uae30\uc5d0 \ube44\uc5b4\uc788\ub294 \ud398\uc5b4\ub9c1 \uc7a5\uc18c\uac00 \uc5c6\uc5b4 \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "max_tries_error": "\uae30\uae30\uac00 \uc2e4\ud328\ud55c \uc778\uc99d \uc2dc\ub3c4 \ud69f\uc218\uac00 100 \ud68c\ub97c \ucd08\uacfc\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "pairing_failed": "\uc774 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\uc744 \uc2dc\ub3c4\ud558\ub294 \uc911 \ucc98\ub9ac\ub418\uc9c0 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc77c\uc2dc\uc801\uc778 \uc624\ub958\uc774\uac70\ub098 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uc7a5\uce58 \uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "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}", "step": { "pair": { "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\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", + "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" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json index 6d338689d1f..882a1d3bc3a 100644 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "D'Kupplung kann net dob\u00e4igesat ginn, well den Apparat net m\u00e9i siichtbar ass", "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.", "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.", @@ -9,16 +10,21 @@ }, "error": { "authentication_error": "Ong\u00ebltege HomeKit Code. Iwwerpr\u00e9ift d\u00ebsen an prob\u00e9iert w.e.g. nach emol.", + "busy_error": "Den Apparat huet en Kupplungs Versuch refus\u00e9iert, well en scho mat engem anere Kontroller verbonnen ass.", + "max_peers_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et keng fr\u00e4i Pairing Memoire huet.", + "max_tries_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et m\u00e9i w\u00e9i 100 net erfollegr\u00e4ich Authentifikatioun's Versich erhalen huet.", + "pairing_failed": "Eng onerwaarte Feeler ass opgetruede beim Kupplung's Versuch mat d\u00ebsem Apparat. D\u00ebst kann e tempor\u00e4re Feeler sinn oder \u00c4ren Apparat g\u00ebtt aktuell net \u00ebnnerst\u00ebtzt.", "unable_to_pair": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", "unknown_error": "Apparat mellt een onbekannte Feeler. Verbindung net m\u00e9iglech." }, + "flow_title": "HomeKit Accessoire: {name}", "step": { "pair": { "data": { "pairing_code": "Pairing Code" }, "description": "Gitt \u00e4ren HomeKit pairing Code an fir d\u00ebsen Accessoire ze benotzen", - "title": "Mat {{ model }} verbannen" + "title": "Mam HomeKit Accessoire verbannen" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json index fcabd40d3be..30380344d9b 100644 --- a/homeassistant/components/homekit_controller/.translations/nl.json +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -14,7 +14,7 @@ "data": { "pairing_code": "Koppelingscode" }, - "title": "Koppel met {{model}}" + "title": "Koppel met HomeKit accessoire" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index 53250833755..555faef1061 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -12,13 +12,14 @@ "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." }, + "flow_title": "HomeKit Tilbeh\u00f8r: {name}", "step": { "pair": { "data": { "pairing_code": "Sammenkoblingskode" }, "description": "Skriv inn HomeKit sammenkoblingskoden for \u00e5 bruke dette tilbeh\u00f8ret", - "title": "Sammenkoble {{ model }}" + "title": "Koble til HomeKit tilbeh\u00f8r" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index 11efebf250e..acbc6ee81f7 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -12,6 +12,7 @@ "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.", "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." }, + "flow_title": "Akcesoria HomeKit: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json index 983afda5e9d..44b4faf455f 100644 --- a/homeassistant/components/homekit_controller/.translations/ru.json +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "\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, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043e.", "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", @@ -9,15 +10,20 @@ }, "error": { "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "busy_error": "\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, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u043e \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0438\u0437-\u0437\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u044f \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u0430.", + "max_tries_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0431\u044b\u043b\u043e \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0431\u043e\u043b\u0435\u0435 100 \u043d\u0435\u0443\u0434\u0430\u0447\u043d\u044b\u0445 \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "pairing_failed": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0439 \u0441\u0431\u043e\u0439 \u0438\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0435\u0449\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c." }, + "flow_title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit: {name}", "step": { "pair": { "data": { "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit, \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440", + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit (\u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 XXX-XX-XXX), \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440", "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" }, "user": { @@ -28,6 +34,6 @@ "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" } }, - "title": "HomeKit Accessory" + "title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit" } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json index d1453b64938..32372840031 100644 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -1,10 +1,21 @@ { "config": { - "error": { - "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen." + "abort": { + "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.", + "no_devices": "Inga oparade enheter kunde hittas" }, + "error": { + "authentication_error": "Felaktig HomeKit-kod. V\u00e4nligen kontrollera och f\u00f6rs\u00f6k igen.", + "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen.", + "unknown_error": "Enheten rapporterade ett ok\u00e4nt fel. Parning misslyckades." + }, + "flow_title": "HomeKit-tillbeh\u00f6r: {namn}", "step": { "pair": { + "data": { + "pairing_code": "Parningskod" + }, + "description": "Ange din HomeKit-parningskod f\u00f6r att anv\u00e4nda det h\u00e4r tillbeh\u00f6ret", "title": "Para HomeKit-tillbeh\u00f6r" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/th.json b/homeassistant/components/homekit_controller/.translations/th.json index a67945c8135..c0311b0f198 100644 --- a/homeassistant/components/homekit_controller/.translations/th.json +++ b/homeassistant/components/homekit_controller/.translations/th.json @@ -18,7 +18,7 @@ "pairing_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48" }, "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e0a\u0e49\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49", - "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a {{ model }}" + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json index a83b5be1f0a..d8c7ba8c4da 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -18,7 +18,7 @@ "pairing_code": "\u914d\u5bf9\u4ee3\u7801" }, "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", - "title": "\u4e0e {{model}} \u914d\u5bf9" + "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json index cbe819fdaeb..25ca625d7df 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hant.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "\u627e\u4e0d\u5230\u88dd\u7f6e\uff0c\u7121\u6cd5\u65b0\u589e\u914d\u5c0d\u3002", "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210", "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002", @@ -9,16 +10,21 @@ }, "error": { "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "busy_error": "\u88dd\u7f6e\u5df2\u7d93\u8207\u5176\u4ed6\u63a7\u5236\u5668\u914d\u5c0d\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_peers_error": "\u88dd\u7f6e\u5df2\u7121\u5269\u9918\u914d\u5c0d\u7a7a\u9593\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_tries_error": "\u88dd\u7f6e\u6536\u5230\u8d85\u904e 100 \u6b21\u672a\u6210\u529f\u8a8d\u8b49\u5f8c\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "pairing_failed": "\u7576\u8a66\u5716\u8207\u88dd\u7f6e\u914d\u5c0d\u6642\u767c\u751f\u7121\u6cd5\u8655\u7406\u932f\u8aa4\uff0c\u53ef\u80fd\u50c5\u70ba\u66ab\u6642\u5931\u6548\u3001\u6216\u8005\u88dd\u7f6e\u76ee\u524d\u4e0d\u652f\u63f4\u3002", "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002" }, + "flow_title": "HomeKit \u914d\u4ef6\uff1a{name}", "step": { "pair": { "data": { "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc" }, "description": "\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u4ee3\u78bc", - "title": "{{ model }} \u914d\u5c0d" + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 11026d7e9ac..1b1c7b96b58 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -8,9 +8,10 @@ from homeassistant.helpers.entity import Entity from .config_flow import load_old_pairings from .connection import get_accessory_information, HKDevice from .const import ( - CONTROLLER, KNOWN_DEVICES + CONTROLLER, ENTITY_MAP, KNOWN_DEVICES ) from .const import DOMAIN # noqa: pylint: disable=unused-import +from .storage import EntityMapStorage HOMEKIT_IGNORE = [ 'BSB002', @@ -44,7 +45,7 @@ class HomeKitEntity(Entity): # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes - pairing_data = self._accessory.pairing.pairing_data + accessories = self._accessory.accessories get_uuid = CharacteristicsTypes.get_uuid characteristic_types = [ @@ -55,7 +56,7 @@ class HomeKitEntity(Entity): self._chars = {} self._char_names = {} - for accessory in pairing_data.get('accessories', []): + for accessory in accessories: if accessory['aid'] != self._aid: continue self._accessory_info = get_accessory_information(accessory) @@ -149,15 +150,22 @@ class HomeKitEntity(Entity): raise NotImplementedError -def setup(hass, config): +async def async_setup(hass, config): """Set up for Homekit devices.""" # pylint: disable=import-error import homekit from homekit.controller.ip_implementation import IpPairing + map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) + await map_storage.async_initialize() + hass.data[CONTROLLER] = controller = homekit.Controller() - for hkid, pairing_data in load_old_pairings(hass).items(): + old_pairings = await hass.async_add_executor_job( + load_old_pairings, + hass + ) + for hkid, pairing_data in old_pairings.items(): controller.pairings[hkid] = IpPairing(pairing_data) def discovery_dispatch(service, discovery_info): @@ -185,12 +193,22 @@ def setup(hass, config): device = hass.data[KNOWN_DEVICES][hkid] if config_num > device.config_num and \ device.pairing is not None: - device.accessory_setup() + device.refresh_entity_map(config_num) return _LOGGER.debug('Discovered unique device %s', hkid) - HKDevice(hass, host, port, model, hkid, config_num, config) + device = HKDevice(hass, host, port, model, hkid, config_num, config) + device.setup() hass.data[KNOWN_DEVICES] = {} - discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) + + await hass.async_add_executor_job( + discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch) + return True + + +async def async_remove_entry(hass, entry): + """Cleanup caches before removing config entry.""" + hkid = entry.data['AccessoryPairingID'] + hass.data[ENTITY_MAP].async_delete_map(hkid) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 2cbd8f6d700..4c299d1c7d0 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -3,8 +3,9 @@ import logging from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY) + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS from . import KNOWN_DEVICES, HomeKitEntity @@ -16,6 +17,7 @@ MODE_HOMEKIT_TO_HASS = { 0: STATE_OFF, 1: STATE_HEAT, 2: STATE_COOL, + 3: STATE_AUTO, } # Map of hass operation modes to homekit modes @@ -43,6 +45,10 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): self._target_temp = None self._current_humidity = None self._target_humidity = None + self._min_target_temp = None + self._max_target_temp = None + self._min_target_humidity = None + self._max_target_humidity = None super().__init__(*args) def get_characteristic_types(self): @@ -86,9 +92,23 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): def _setup_temperature_target(self, characteristic): self._features |= SUPPORT_TARGET_TEMPERATURE + if 'minValue' in characteristic: + self._min_target_temp = characteristic['minValue'] + + if 'maxValue' in characteristic: + self._max_target_temp = characteristic['maxValue'] + def _setup_relative_humidity_target(self, characteristic): self._features |= SUPPORT_TARGET_HUMIDITY + if 'minValue' in characteristic: + self._min_target_humidity = characteristic['minValue'] + self._features |= SUPPORT_TARGET_HUMIDITY_LOW + + if 'maxValue' in characteristic: + self._max_target_humidity = characteristic['maxValue'] + self._features |= SUPPORT_TARGET_HUMIDITY_HIGH + def _update_heating_cooling_current(self, value): self._state = MODE_HOMEKIT_TO_HASS.get(value) @@ -152,6 +172,20 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): """Return the temperature we try to reach.""" return self._target_temp + @property + def min_temp(self): + """Return the minimum target temp.""" + if self._max_target_temp: + return self._min_target_temp + return super().min_temp + + @property + def max_temp(self): + """Return the maximum target temp.""" + if self._max_target_temp: + return self._max_target_temp + return super().max_temp + @property def current_humidity(self): """Return the current humidity.""" @@ -162,6 +196,16 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): """Return the humidity we try to reach.""" return self._target_humidity + @property + def min_humidity(self): + """Return the minimum humidity.""" + return self._min_target_humidity + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return self._max_target_humidity + @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 1cd66896fe2..197d15116b1 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -130,6 +130,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): status_flags = int(properties['sf']) paired = not status_flags & 0x01 + # pylint: disable=unsupported-assignment-operation + self.context['title_placeholders'] = { + 'name': discovery_info['name'], + } + # The configuration number increases every time the characteristic map # needs updating. Some devices use a slightly off-spec name so handle # both cases. @@ -152,7 +157,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): "HomeKit info %s: c# incremented, refreshing entities", hkid) self.hass.async_create_task( - conn.async_config_num_changed(config_num)) + conn.async_refresh_entity_map(config_num)) return self.async_abort(reason='already_configured') old_pairings = await self.hass.async_add_executor_job( @@ -232,8 +237,21 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): errors['pairing_code'] = 'authentication_error' except homekit.UnknownError: errors['pairing_code'] = 'unknown_error' + except homekit.MaxTriesError: + errors['pairing_code'] = 'max_tries_error' + except homekit.BusyError: + errors['pairing_code'] = 'busy_error' + except homekit.MaxPeersError: + errors['pairing_code'] = 'max_peers_error' + except homekit.AccessoryNotFoundError: + return self.async_abort(reason='accessory_not_found_error') except homekit.UnavailableError: return self.async_abort(reason='already_paired') + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Pairing attempt failed with an unhandled exception" + ) + errors['pairing_code'] = 'pairing_failed' return self.async_show_form( step_id='pair', diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 032032d30ab..af438c68164 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -4,11 +4,10 @@ import logging import os from homeassistant.helpers import discovery -from homeassistant.helpers.event import call_later from .const import ( CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES, - PAIRING_FILE, HOMEKIT_DIR + PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP ) @@ -67,7 +66,7 @@ class HKDevice(): self.config_num = config_num self.config = config self.configurator = hass.components.configurator - self._connection_warning_logged = False + self.accessories = {} # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. @@ -79,27 +78,78 @@ class HKDevice(): hass.data[KNOWN_DEVICES][hkid] = self - if self.pairing is not None: - self.accessory_setup() - else: + def setup(self): + """Prepare to use a paired HomeKit device in homeassistant.""" + if self.pairing is None: self.configure() - - def accessory_setup(self): - """Handle setup of a HomeKit accessory.""" - # pylint: disable=import-error - from homekit.model.services import ServicesTypes - from homekit.exceptions import AccessoryDisconnectedError + return self.pairing.pairing_data['AccessoryIP'] = self.host self.pairing.pairing_data['AccessoryPort'] = self.port + cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id) + if not cache or cache['config_num'] < self.config_num: + return self.refresh_entity_map(self.config_num) + + self.accessories = cache['accessories'] + + # Ensure the Pairing object has access to the latest version of the + # entity map. + self.pairing.pairing_data['accessories'] = self.accessories + + self.add_entities() + + return True + + def refresh_entity_map(self, config_num): + """ + Handle setup of a HomeKit accessory. + + The sync version will be removed when homekit_controller migrates to + config flow. + """ + self.hass.add_job( + self.async_refresh_entity_map, + config_num, + ) + + async def async_refresh_entity_map(self, config_num): + """Handle setup of a HomeKit accessory.""" + # pylint: disable=import-error + from homekit.exceptions import AccessoryDisconnectedError + try: - data = self.pairing.list_accessories_and_characteristics() + self.accessories = await self.hass.async_add_executor_job( + self.pairing.list_accessories_and_characteristics, + ) except AccessoryDisconnectedError: - call_later( - self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup()) + # If we fail to refresh this data then we will naturally retry + # later when Bonjour spots c# is still not up to date. return - for accessory in data: + + self.hass.data[ENTITY_MAP].async_create_or_update_map( + self.unique_id, + config_num, + self.accessories, + ) + + self.config_num = config_num + + # For BLE, the Pairing instance relies on the entity map to map + # aid/iid to GATT characteristics. So push it to there as well. + self.pairing.pairing_data['accessories'] = self.accessories + + # Register add new entities that are available + await self.hass.async_add_executor_job(self.add_entities) + + return True + + def add_entities(self): + """Process the entity map and create HA entities.""" + # pylint: disable=import-error + from homekit.model.services import ServicesTypes + + for accessory in self.accessories: aid = accessory['aid'] for service in accessory['services']: iid = service['iid'] @@ -118,6 +168,7 @@ class HKDevice(): if component is not None: discovery.load_platform(self.hass, component, DOMAIN, service_info, self.config) + self.entities.append((aid, iid)) def device_config_callback(self, callback_data): """Handle initial pairing.""" @@ -158,7 +209,7 @@ class HKDevice(): self.controller.save_data(pairing_file) _configurator = self.hass.data[DOMAIN+self.hkid] self.configurator.request_done(_configurator) - self.accessory_setup() + self.setup() else: error_msg = "Unable to pair, please try again" _configurator = self.hass.data[DOMAIN+self.hkid] @@ -202,3 +253,12 @@ class HKDevice(): self.pairing.put_characteristics, chars ) + + @property + def unique_id(self): + """ + Return a unique id for this accessory or bridge. + + This id is random and will change if a device undergoes a hard reset. + """ + return self.hkid diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index de9663f1202..f112737ca24 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -3,6 +3,7 @@ DOMAIN = 'homekit_controller' KNOWN_DEVICES = "{}-devices".format(DOMAIN) CONTROLLER = "{}-controller".format(DOMAIN) +ENTITY_MAP = '{}-entity-map'.format(DOMAIN) HOMEKIT_DIR = '.homekit' PAIRING_FILE = 'pairing.json' diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e724f680b60..c1b923a5677 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,8 +3,10 @@ "name": "Homekit controller", "documentation": "https://www.home-assistant.io/components/homekit_controller", "requirements": [ - "homekit[IP]==0.13.0" + "homekit[IP]==0.14.0" ], "dependencies": ["configurator"], - "codeowners": [] + "codeowners": [ + "@Jc2k" + ] } diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py new file mode 100644 index 00000000000..4a7c0a8057b --- /dev/null +++ b/homeassistant/components/homekit_controller/storage.py @@ -0,0 +1,80 @@ +"""Helpers for HomeKit data stored in HA storage.""" + +from homeassistant.helpers.storage import Store +from homeassistant.core import callback + +from .const import DOMAIN + +ENTITY_MAP_STORAGE_KEY = '{}-entity-map'.format(DOMAIN) +ENTITY_MAP_STORAGE_VERSION = 1 +ENTITY_MAP_SAVE_DELAY = 10 + + +class EntityMapStorage: + """ + Holds a cache of entity structure data from a paired HomeKit device. + + HomeKit has a cacheable entity map that describes how an IP or BLE + endpoint is structured. This object holds the latest copy of that data. + + An endpoint is made of accessories, services and characteristics. It is + safe to cache this data until the c# discovery data changes. + + Caching this data means we can add HomeKit devices to HA immediately at + start even if discovery hasn't seen them yet or they are out of range. It + is also important for BLE devices - accessing the entity structure is + very slow for these devices. + """ + + def __init__(self, hass): + """Create a new entity map store.""" + self.hass = hass + self.store = Store( + hass, + ENTITY_MAP_STORAGE_VERSION, + ENTITY_MAP_STORAGE_KEY + ) + self.storage_data = {} + + async def async_initialize(self): + """Get the pairing cache data.""" + raw_storage = await self.store.async_load() + if not raw_storage: + # There is no cached data about HomeKit devices yet + return + + self.storage_data = raw_storage.get('pairings', {}) + + def get_map(self, homekit_id): + """Get a pairing cache item.""" + return self.storage_data.get(homekit_id) + + def async_create_or_update_map(self, homekit_id, config_num, accessories): + """Create a new pairing cache.""" + data = { + 'config_num': config_num, + 'accessories': accessories, + } + self.storage_data[homekit_id] = data + self._async_schedule_save() + return data + + def async_delete_map(self, homekit_id): + """Delete pairing cache.""" + if homekit_id not in self.storage_data: + return + + self.storage_data.pop(homekit_id) + self._async_schedule_save() + + @callback + def _async_schedule_save(self): + """Schedule saving the entity map cache.""" + self.store.async_delay_save(self._data_to_save, ENTITY_MAP_SAVE_DELAY) + + @callback + def _data_to_save(self): + """Return data of entity map to store in a file.""" + return { + 'pairings': self.storage_data, + } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index b1601a1f33e..eceaa624b0f 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -1,6 +1,7 @@ { "config": { "title": "HomeKit Accessory", + "flow_title": "HomeKit Accessory: {name}", "step": { "user": { "title": "Pair with HomeKit Accessory", @@ -11,7 +12,7 @@ }, "pair": { "title": "Pair with HomeKit Accessory", - "description": "Enter your HomeKit pairing code to use this accessory", + "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory", "data": { "pairing_code": "Pairing Code" } @@ -20,14 +21,19 @@ "error": { "unable_to_pair": "Unable to pair, please try again.", "unknown_error": "Device reported an unknown error. Pairing failed.", - "authentication_error": "Incorrect HomeKit code. Please check it and try again." + "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", + "busy_error": "Device refused to add pairing as it is already pairing with another controller.", + "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", + "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently." }, "abort": { "no_devices": "No unpaired devices could be found", "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", "already_configured": "Accessory is already configured with this controller.", - "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", + "accessory_not_found_error": "Cannot add pairing as device can no longer be found." } } } diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 747b23bb970..578fae064f8 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -2,7 +2,6 @@ from datetime import timedelta from functools import partial import logging -import socket import voluptuous as vol @@ -263,7 +262,7 @@ def setup(hass, config): # Create hosts-dictionary for pyhomematic for rname, rconfig in conf[CONF_INTERFACES].items(): remotes[rname] = { - 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), + 'ip': rconfig.get(CONF_HOST), 'port': rconfig.get(CONF_PORT), 'path': rconfig.get(CONF_PATH), 'resolvenames': rconfig.get(CONF_RESOLVENAMES), @@ -279,7 +278,7 @@ def setup(hass, config): for sname, sconfig in conf[CONF_HOSTS].items(): remotes[sname] = { - 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), + 'ip': sconfig.get(CONF_HOST), 'port': DEFAULT_PORT, 'username': sconfig.get(CONF_USERNAME), 'password': sconfig.get(CONF_PASSWORD), diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index a1c33a10e93..f7c14970982 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -21,7 +21,7 @@ "title": "Tria el punt d'acc\u00e9s HomematicIP" }, "link": { - "description": "Prem el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 d'enviar per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Prem el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 Envia per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Enlla\u00e7 amb punt d'acc\u00e9s" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json index 185dbd338f9..206bd05a345 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es.json +++ b/homeassistant/components/homematicip_cloud/.translations/es.json @@ -21,7 +21,7 @@ "title": "Elegir punto de acceso HomematicIP" }, "link": { - "description": "Presione el bot\u00f3n azul en el punto de acceso y el bot\u00f3n enviar para registrar HomematicIP con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n punto de acceso](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Pulsa el bot\u00f3n azul en el punto de acceso y el bot\u00f3n de env\u00edo para registrar HomematicIP en Home Assistant.\n\n![Ubicaci\u00f3n del bot\u00f3n en el puente](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Enlazar punto de acceso" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json index dde9345b2d2..82ecd4a3250 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -9,7 +9,7 @@ "invalid_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", "press_the_button": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443.", "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430", - "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u0438\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430" + "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430" }, "step": { "init": { @@ -21,7 +21,7 @@ "title": "HomematicIP Cloud" }, "link": { - "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430" } }, diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 4a24120be95..550ba43950b 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -4,9 +4,12 @@ import logging import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .config_flow import configured_haps from .const import ( @@ -26,7 +29,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomematicIP Cloud component.""" hass.data[DOMAIN] = {} @@ -46,7 +49,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an access point from a config entry.""" hap = HomematicipHAP(hass, entry) hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index cb35833c231..1e072c6784c 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -1,15 +1,23 @@ """Support for HomematicIP Cloud alarm control panel.""" import logging +from homematicip.aio.group import AsyncSecurityZoneGroup +from homematicip.aio.home import AsyncHome +from homematicip.base.enums import WindowState + from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice _LOGGER = logging.getLogger(__name__) +CONST_ALARM_CONTROL_PANEL_NAME = 'HmIP Alarm Control Panel' + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -17,15 +25,23 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the HomematicIP alarm control panel from a config entry.""" - from homematicip.aio.group import AsyncSecurityZoneGroup - +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: + """Set up the HomematicIP alrm control panel from a config entry.""" home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] + security_zones = [] for group in home.groups: if isinstance(group, AsyncSecurityZoneGroup): + security_zones.append(group) + # To be removed in a later release. devices.append(HomematicipSecurityZone(home, group)) + _LOGGER.warning("Homematic IP: alarm_control_panel.%s is " + "deprecated. Please switch to " + "alarm_control_panel.*hmip_alarm_control_panel.", + group.label) + if security_zones: + devices.append(HomematicipAlarmControlPanel(home, security_zones)) if devices: async_add_entities(devices) @@ -34,17 +50,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): """Representation of an HomematicIP Cloud security zone group.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the security zone group.""" device.modelType = 'Group-SecurityZone' device.windowState = None super().__init__(home, device) @property - def state(self): + def state(self) -> str: """Return the state of the device.""" - from homematicip.base.enums import WindowState - if self._device.active: if (self._device.sabotage or self._device.motionDetected or self._device.windowState == WindowState.OPEN or @@ -70,3 +84,104 @@ class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): async def async_alarm_arm_away(self, code=None): """Send arm away command.""" await self._home.set_security_zones_activation(True, True) + + +class HomematicipAlarmControlPanel(AlarmControlPanel): + """Representation of an alarm control panel.""" + + def __init__(self, home: AsyncHome, security_zones) -> None: + """Initialize the alarm control panel.""" + self._home = home + self.alarm_state = STATE_ALARM_DISARMED + + for security_zone in security_zones: + if security_zone.label == 'INTERNAL': + self._internal_alarm_zone = security_zone + else: + self._external_alarm_zone = security_zone + + @property + def state(self) -> str: + """Return the state of the device.""" + activation_state = self._home.get_security_zones_activation() + # check arm_away + if activation_state == (True, True): + if self._internal_alarm_zone_state or \ + self._external_alarm_zone_state: + return STATE_ALARM_TRIGGERED + return STATE_ALARM_ARMED_AWAY + # check arm_home + if activation_state == (False, True): + if self._external_alarm_zone_state: + return STATE_ALARM_TRIGGERED + return STATE_ALARM_ARMED_HOME + + return STATE_ALARM_DISARMED + + @property + def _internal_alarm_zone_state(self) -> bool: + return _get_zone_alarm_state(self._internal_alarm_zone) + + @property + def _external_alarm_zone_state(self) -> bool: + """Return the state of the device.""" + return _get_zone_alarm_state(self._external_alarm_zone) + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._home.set_security_zones_activation(False, False) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self._home.set_security_zones_activation(False, True) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._home.set_security_zones_activation(True, True) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._internal_alarm_zone.on_update(self._async_device_changed) + self._external_alarm_zone.on_update(self._async_device_changed) + + def _async_device_changed(self, *args, **kwargs): + """Handle device state changes.""" + _LOGGER.debug("Event %s (%s)", self.name, + CONST_ALARM_CONTROL_PANEL_NAME) + self.async_schedule_update_ha_state() + + @property + def name(self) -> str: + """Return the name of the generic device.""" + name = CONST_ALARM_CONTROL_PANEL_NAME + if self._home.name: + name = "{} {}".format(self._home.name, name) + return name + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def available(self) -> bool: + """Device available.""" + return not self._internal_alarm_zone.unreach or \ + not self._external_alarm_zone.unreach + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}_{}".format(self.__class__.__name__, self._home.id) + + +def _get_zone_alarm_state(security_zone) -> bool: + if security_zone.active: + if (security_zone.sabotage or + security_zone.motionDetected or + security_zone.presenceDetected or + security_zone.windowState == WindowState.OPEN or + security_zone.windowState == WindowState.TILTED): + return True + + return False diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 48e9520a952..19d35c47cdb 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -1,7 +1,18 @@ """Support for HomematicIP Cloud binary sensor.""" import logging +from homematicip.aio.device import ( + AsyncDevice, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, AsyncRotaryHandleSensor, + AsyncShutterContact, AsyncSmokeDetector, AsyncWaterSensor, + AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) +from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup +from homematicip.aio.home import AsyncHome +from homematicip.base.enums import SmokeDetectorAlarmType, WindowState + from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice from .device import ATTR_GROUP_MEMBER_UNREACHABLE @@ -24,17 +35,9 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" - from homematicip.aio.device import ( - AsyncDevice, AsyncShutterContact, AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, AsyncSmokeDetector, AsyncWaterSensor, - AsyncRotaryHandleSensor, AsyncMotionDetectorPushButton, - AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - - from homematicip.aio.group import ( - AsyncSecurityGroup, AsyncSecurityZoneGroup) - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: @@ -72,16 +75,14 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud shutter contact.""" @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'door' @property - def is_on(self): + def is_on(self) -> bool: """Return true if the shutter contact is on/open.""" - from homematicip.base.enums import WindowState - - if self._device.sabotage: + if hasattr(self._device, 'sabotage') and self._device.sabotage: return True if self._device.windowState is None: return None @@ -92,14 +93,14 @@ class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud motion detector.""" @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'motion' @property - def is_on(self): + def is_on(self) -> bool: """Return true if motion is detected.""" - if self._device.sabotage: + if hasattr(self._device, 'sabotage') and self._device.sabotage: return True return self._device.motionDetected @@ -108,14 +109,13 @@ class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud smoke detector.""" @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'smoke' @property - def is_on(self): + def is_on(self) -> bool: """Return true if smoke is detected.""" - from homematicip.base.enums import SmokeDetectorAlarmType return (self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF) @@ -124,12 +124,12 @@ class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud water detector.""" @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'moisture' @property - def is_on(self): + def is_on(self) -> bool: """Return true, if moisture or waterlevel is detected.""" return self._device.moistureDetected or self._device.waterlevelDetected @@ -137,17 +137,17 @@ class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud storm sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize storm sensor.""" super().__init__(home, device, "Storm") @property - def icon(self): + def icon(self) -> str: """Return the icon.""" return 'mdi:weather-windy' if self.is_on else 'mdi:pinwheel-outline' @property - def is_on(self): + def is_on(self) -> bool: """Return true, if storm is detected.""" return self._device.storm @@ -155,17 +155,17 @@ class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud rain sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize rain sensor.""" super().__init__(home, device, "Raining") @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'moisture' @property - def is_on(self): + def is_on(self) -> bool: """Return true, if it is raining.""" return self._device.raining @@ -173,17 +173,17 @@ class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud sunshine sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize sunshine sensor.""" super().__init__(home, device, 'Sunshine') @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'light' @property - def is_on(self): + def is_on(self) -> bool: """Return true if sun is shining.""" return self._device.sunshine @@ -201,17 +201,17 @@ class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud low battery sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize battery sensor.""" super().__init__(home, device, 'Battery') @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'battery' @property - def is_on(self): + def is_on(self) -> bool: """Return true if battery is low.""" return self._device.lowBat @@ -220,18 +220,19 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud security zone group.""" - def __init__(self, home, device, post='SecurityZone'): + def __init__(self, home: AsyncHome, device, + post: str = 'SecurityZone') -> None: """Initialize security zone group.""" device.modelType = 'HmIP-{}'.format(post) super().__init__(home, device, post) @property - def device_class(self): + def device_class(self) -> str: """Return the class of this sensor.""" return 'safety' @property - def available(self): + def available(self) -> bool: """Security-Group available.""" # A security-group must be available, and should not be affected by # the individual availability of group members. @@ -246,7 +247,7 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, attr[ATTR_MOTIONDETECTED] = True if self._device.presenceDetected: attr[ATTR_PRESENCEDETECTED] = True - from homematicip.base.enums import WindowState + if self._device.windowState is not None and \ self._device.windowState != WindowState.CLOSED: attr[ATTR_WINDOWSTATE] = str(self._device.windowState) @@ -255,14 +256,14 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, return attr @property - def is_on(self): + def is_on(self) -> bool: """Return true if security issue detected.""" if self._device.motionDetected or \ self._device.presenceDetected or \ self._device.unreach or \ self._device.sabotage: return True - from homematicip.base.enums import WindowState + if self._device.windowState is not None and \ self._device.windowState != WindowState.CLOSED: return True @@ -273,7 +274,7 @@ class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup, BinarySensorDevice): """Representation of a HomematicIP security group.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize security group.""" super().__init__(home, device, 'Sensors') @@ -288,7 +289,7 @@ class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup, attr[ATTR_MOISTUREDETECTED] = True if self._device.waterlevelDetected: attr[ATTR_WATERLEVELDETECTED] = True - from homematicip.base.enums import SmokeDetectorAlarmType + if self._device.smokeDetectorAlarmType is not None and \ self._device.smokeDetectorAlarmType != \ SmokeDetectorAlarmType.IDLE_OFF: @@ -298,10 +299,9 @@ class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup, return attr @property - def is_on(self): + def is_on(self) -> bool: """Return true if safety issue detected.""" parent_is_on = super().is_on - from homematicip.base.enums import SmokeDetectorAlarmType if parent_is_on or \ self._device.powerMainsFailure or \ self._device.moistureDetected or \ diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 5055858e9c7..3170fc149d5 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -1,10 +1,15 @@ """Support for HomematicIP Cloud climate devices.""" import logging +from homematicip.aio.group import AsyncHeatingGroup +from homematicip.aio.home import AsyncHome + from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( STATE_AUTO, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice @@ -24,14 +29,13 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP climate from a config entry.""" - from homematicip.group import HeatingGroup - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.groups: - if isinstance(device, HeatingGroup): + if isinstance(device, AsyncHeatingGroup): devices.append(HomematicipHeatingGroup(home, device)) if devices: @@ -41,48 +45,48 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): """Representation of a HomematicIP heating group.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize heating group.""" device.modelType = 'Group-Heating' super().__init__(home, device) @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" return SUPPORT_TARGET_TEMPERATURE @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._device.setPointTemperature @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._device.actualTemperature @property - def current_humidity(self): + def current_humidity(self) -> int: """Return the current humidity.""" return self._device.humidity @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation ie. automatic or manual.""" return HMIP_STATE_TO_HA.get(self._device.controlMode) @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._device.minTemperature @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._device.maxTemperature diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 458186bcce1..696425df5b5 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,8 +1,10 @@ """Config flow to configure the HomematicIP Cloud component.""" +from typing import Set + import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import ( _LOGGER, DOMAIN as HMIPC_DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, @@ -11,7 +13,7 @@ from .hap import HomematicipAuth @callback -def configured_haps(hass): +def configured_haps(hass: HomeAssistant) -> Set[str]: """Return a set of the configured access points.""" return set(entry.data[HMIPC_HAPID] for entry in hass.config_entries.async_entries(HMIPC_DOMAIN)) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index e572e3d9754..fc75d78119d 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -1,7 +1,12 @@ """Support for HomematicIP Cloud cover devices.""" import logging +from typing import Optional + +from homematicip.aio.device import AsyncFullFlushShutter from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice @@ -17,10 +22,9 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP cover from a config entry.""" - from homematicip.aio.device import AsyncFullFlushShutter - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: @@ -35,7 +39,7 @@ class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice): """Representation of a HomematicIP Cloud cover device.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return current position of cover.""" return int((1 - self._device.shutterLevel) * 100) @@ -47,7 +51,7 @@ class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice): await self._device.set_shutter_level(level) @property - def is_closed(self): + def is_closed(self) -> Optional[bool]: """Return if the cover is closed.""" if self._device.shutterLevel is not None: return self._device.shutterLevel == HMIP_COVER_CLOSED diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 0b815d0ec7e..2c77d225263 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -1,5 +1,9 @@ """Generic device for the HomematicIP Cloud component.""" import logging +from typing import Optional + +from homematicip.aio.device import AsyncDevice +from homematicip.aio.home import AsyncHome from homeassistant.components import homematicip_cloud from homeassistant.helpers.entity import Entity @@ -19,7 +23,8 @@ ATTR_GROUP_MEMBER_UNREACHABLE = 'group_member_unreachable' class HomematicipGenericDevice(Entity): """Representation of an HomematicIP generic device.""" - def __init__(self, home, device, post=None): + def __init__(self, home: AsyncHome, device, + post: Optional[str] = None) -> None: """Initialize the generic device.""" self._home = home self._device = device @@ -29,7 +34,6 @@ class HomematicipGenericDevice(Entity): @property def device_info(self): """Return device specific attributes.""" - from homematicip.aio.device import AsyncDevice # Only physical devices should be HA devices. if isinstance(self._device, AsyncDevice): return { @@ -47,15 +51,15 @@ class HomematicipGenericDevice(Entity): async def async_added_to_hass(self): """Register callbacks.""" - self._device.on_update(self._device_changed) + self._device.on_update(self._async_device_changed) - def _device_changed(self, *args, **kwargs): + def _async_device_changed(self, *args, **kwargs): """Handle device state changes.""" _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType) self.async_schedule_update_ha_state() @property - def name(self): + def name(self) -> str: """Return the name of the generic device.""" name = self._device.label if self._home.name is not None and self._home.name != '': @@ -65,22 +69,22 @@ class HomematicipGenericDevice(Entity): return name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed.""" return False @property - def available(self): + def available(self) -> bool: """Device available.""" return not self._device.unreach @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return "{}_{}".format(self.__class__.__name__, self._device.id) @property - def icon(self): + def icon(self) -> Optional[str]: """Return the icon.""" if hasattr(self._device, 'lowBat') and self._device.lowBat: return 'mdi:battery-outline' diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 64721c0a96c..b3731bc9f1a 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -2,7 +2,12 @@ import asyncio import logging -from homeassistant.core import callback +from homematicip.aio.auth import AsyncAuth +from homematicip.aio.home import AsyncHome +from homematicip.base.base_connection import HmipConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -36,8 +41,6 @@ class HomematicipAuth: async def async_checkbutton(self): """Check blue butten has been pressed.""" - from homematicip.base.base_connection import HmipConnectionError - try: return await self.auth.isRequestAcknowledged() except HmipConnectionError: @@ -45,8 +48,6 @@ class HomematicipAuth: async def async_register(self): """Register client at HomematicIP.""" - from homematicip.base.base_connection import HmipConnectionError - try: authtoken = await self.auth.requestAuthToken() await self.auth.confirmAuthToken(authtoken) @@ -56,9 +57,6 @@ class HomematicipAuth: async def get_auth(self, hass, hapid, pin): """Create a HomematicIP access point object.""" - from homematicip.aio.auth import AsyncAuth - from homematicip.base.base_connection import HmipConnectionError - auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) try: await auth.init(hapid) @@ -73,7 +71,7 @@ class HomematicipAuth: class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry @@ -84,7 +82,7 @@ class HomematicipHAP: self._tries = 0 self._accesspoint_connected = True - async def async_setup(self, tries=0): + async def async_setup(self, tries: int = 0): """Initialize connection.""" try: self.home = await self.get_hap( @@ -138,8 +136,6 @@ class HomematicipHAP: def get_state_finished(self, future): """Execute when get_state coroutine has finished.""" - from homematicip.base.base_connection import HmipConnectionError - try: future.result() except HmipConnectionError: @@ -162,8 +158,6 @@ class HomematicipHAP: async def async_connect(self): """Start WebSocket connection.""" - from homematicip.base.base_connection import HmipConnectionError - tries = 0 while True: retry_delay = 2 ** min(tries, 8) @@ -203,11 +197,9 @@ class HomematicipHAP: self.config_entry, component) return True - async def get_hap(self, hass, hapid, authtoken, name): + async def get_hap(self, hass: HomeAssistant, hapid: str, authtoken: str, + name: str) -> AsyncHome: """Create a HomematicIP access point object.""" - from homematicip.aio.home import AsyncHome - from homematicip.base.base_connection import HmipConnectionError - home = AsyncHome(hass.loop, async_get_clientsession(hass)) home.name = name diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index b67e4114db2..7cfbae95a33 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -1,9 +1,19 @@ """Support for HomematicIP Cloud lights.""" import logging +from homematicip.aio.device import ( + AsyncBrandDimmer, AsyncBrandSwitchMeasuring, + AsyncBrandSwitchNotificationLight, AsyncDimmer, AsyncFullFlushDimmer, + AsyncPluggableDimmer) +from homematicip.aio.home import AsyncHome +from homematicip.base.enums import RGBColorState +from homematicip.base.functionalChannels import NotificationLightChannel + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice @@ -19,12 +29,9 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" - from homematicip.aio.device import AsyncBrandSwitchMeasuring, AsyncDimmer,\ - AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer,\ - AsyncBrandSwitchNotificationLight - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: @@ -48,12 +55,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipLight(HomematicipGenericDevice, Light): """Representation of a HomematicIP Cloud light device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the light device.""" super().__init__(home, device) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.on @@ -83,22 +90,25 @@ class HomematicipLightMeasuring(HomematicipLight): class HomematicipDimmer(HomematicipGenericDevice, Light): """Representation of HomematicIP Cloud dimmer light device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the dimmer light device.""" super().__init__(home, device) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" - return self._device.dimLevel != 0 + return self._device.dimLevel is not None and \ + self._device.dimLevel > 0.0 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return int(self._device.dimLevel*255) + if self._device.dimLevel: + return int(self._device.dimLevel*255) + return 0 @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS @@ -117,15 +127,14 @@ class HomematicipDimmer(HomematicipGenericDevice, Light): class HomematicipNotificationLight(HomematicipGenericDevice, Light): """Representation of HomematicIP Cloud dimmer light device.""" - def __init__(self, home, device, channel_index): + def __init__(self, home: AsyncHome, device, channel: int) -> None: """Initialize the dimmer light device.""" - self._channel_index = channel_index - if self._channel_index == 2: + self.channel = channel + if self.channel == 2: super().__init__(home, device, 'Top') else: super().__init__(home, device, 'Bottom') - from homematicip.base.enums import RGBColorState self._color_switcher = { RGBColorState.WHITE: [0.0, 0.0], RGBColorState.RED: [0.0, 100.0], @@ -137,23 +146,26 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light): } @property - def _channel(self): - return self._device.functionalChannels[self._channel_index] + def _func_channel(self) -> NotificationLightChannel: + return self._device.functionalChannels[self.channel] @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" - return self._channel.dimLevel > 0.0 + return self._func_channel.dimLevel is not None and \ + self._func_channel.dimLevel > 0.0 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return int(self._channel.dimLevel * 255) + if self._func_channel.dimLevel: + return int(self._func_channel.dimLevel * 255) + return 0 @property - def hs_color(self): + def hs_color(self) -> tuple: """Return the hue and saturation color value [float, float].""" - simple_rgb_color = self._channel.simpleRGBColorState + simple_rgb_color = self._func_channel.simpleRGBColorState return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) @property @@ -161,16 +173,16 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light): """Return the state attributes of the generic device.""" attr = super().device_state_attributes if self.is_on: - attr[ATTR_COLOR_NAME] = self._channel.simpleRGBColorState + attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState return attr @property - def name(self): + def name(self) -> str: """Return the name of the generic device.""" return "{} {}".format(super().name, 'Notification') @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS | SUPPORT_COLOR @@ -201,27 +213,25 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light): dim_level = brightness / 255.0 await self._device.set_rgb_dim_level( - self._channel_index, + self.channel, simple_rgb_color, dim_level) async def async_turn_off(self, **kwargs): """Turn the light off.""" - simple_rgb_color = self._channel.simpleRGBColorState + simple_rgb_color = self._func_channel.simpleRGBColorState await self._device.set_rgb_dim_level( - self._channel_index, + self.channel, simple_rgb_color, 0.0) -def _convert_color(color): +def _convert_color(color) -> RGBColorState: """ Convert the given color to the reduced RGBColorState color. RGBColorStat contains only 8 colors including white and black, so a conversion is required. """ - from homematicip.base.enums import RGBColorState - if color is None: return RGBColorState.WHITE diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 201a5be6c51..3d91b25c2bd 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -1,9 +1,23 @@ """Support for HomematicIP Cloud sensors.""" import logging +from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring, + AsyncHeatingThermostat, AsyncHeatingThermostatCompact, AsyncLightSensor, + AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, AsyncPlugableSwitchMeasuring, + AsyncTemperatureHumiditySensorDisplay, + AsyncTemperatureHumiditySensorOutdoor, + AsyncTemperatureHumiditySensorWithoutDisplay, AsyncWeatherSensor, + AsyncWeatherSensorPlus, AsyncWeatherSensorPro) +from homematicip.aio.home import AsyncHome +from homematicip.base.enums import ValveState + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS) +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice @@ -20,24 +34,16 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" - from homematicip.aio.device import ( - AsyncHeatingThermostat, AsyncHeatingThermostatCompact, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorDisplay, AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, AsyncTemperatureHumiditySensorOutdoor, - AsyncMotionDetectorPushButton, AsyncLightSensor, - AsyncPlugableSwitchMeasuring, AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring, AsyncWeatherSensor, - AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] for device in home.devices: if isinstance(device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact)): devices.append(HomematicipHeatingThermostat(home, device)) + devices.append(HomematicipTemperatureSensor(home, device)) if isinstance(device, (AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorWithoutDisplay, AsyncTemperatureHumiditySensorOutdoor, @@ -46,15 +52,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncWeatherSensorPro)): devices.append(HomematicipTemperatureSensor(home, device)) devices.append(HomematicipHumiditySensor(home, device)) - if isinstance(device, (AsyncMotionDetectorIndoor, + if isinstance(device, (AsyncLightSensor, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, AsyncMotionDetectorPushButton, AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): devices.append(HomematicipIlluminanceSensor(home, device)) - if isinstance(device, AsyncLightSensor): - devices.append(HomematicipLightSensor(home, device)) if isinstance(device, (AsyncPlugableSwitchMeasuring, AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring)): @@ -74,7 +78,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipAccesspointStatus(HomematicipGenericDevice): """Representation of an HomeMaticIP Cloud access point.""" - def __init__(self, home): + def __init__(self, home: AsyncHome) -> None: """Initialize access point device.""" super().__init__(home, home) @@ -90,22 +94,22 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): } @property - def icon(self): + def icon(self) -> str: """Return the icon of the access point device.""" return 'mdi:access-point-network' @property - def state(self): + def state(self) -> float: """Return the state of the access point.""" return self._home.dutyCycle @property - def available(self): + def available(self) -> bool: """Device available.""" return self._home.connected @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return '%' @@ -113,15 +117,13 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): class HomematicipHeatingThermostat(HomematicipGenericDevice): """Represenation of a HomematicIP heating thermostat device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize heating thermostat device.""" super().__init__(home, device, 'Heating') @property - def icon(self): + def icon(self) -> str: """Return the icon.""" - from homematicip.base.enums import ValveState - if super().icon: return super().icon if self._device.valveState != ValveState.ADAPTION_DONE: @@ -129,16 +131,14 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice): return 'mdi:radiator' @property - def state(self): + def state(self) -> int: """Return the state of the radiator valve.""" - from homematicip.base.enums import ValveState - if self._device.valveState != ValveState.ADAPTION_DONE: return self._device.valveState return round(self._device.valvePosition*100) @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return '%' @@ -146,22 +146,22 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice): class HomematicipHumiditySensor(HomematicipGenericDevice): """Represenation of a HomematicIP Cloud humidity device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the thermometer device.""" super().__init__(home, device, 'Humidity') @property - def device_class(self): + def device_class(self) -> str: """Return the device class of the sensor.""" return DEVICE_CLASS_HUMIDITY @property - def state(self): + def state(self) -> int: """Return the state.""" return self._device.humidity @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return '%' @@ -169,22 +169,25 @@ class HomematicipHumiditySensor(HomematicipGenericDevice): class HomematicipTemperatureSensor(HomematicipGenericDevice): """Representation of a HomematicIP Cloud thermometer device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the thermometer device.""" super().__init__(home, device, 'Temperature') @property - def device_class(self): + def device_class(self) -> str: """Return the device class of the sensor.""" return DEVICE_CLASS_TEMPERATURE @property - def state(self): + def state(self) -> float: """Return the state.""" + if hasattr(self._device, 'valveActualTemperature'): + return self._device.valveActualTemperature + return self._device.actualTemperature @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TEMP_CELSIUS @@ -201,54 +204,48 @@ class HomematicipTemperatureSensor(HomematicipGenericDevice): class HomematicipIlluminanceSensor(HomematicipGenericDevice): """Represenation of a HomematicIP Illuminance device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the device.""" super().__init__(home, device, 'Illuminance') @property - def device_class(self): + def device_class(self) -> str: """Return the device class of the sensor.""" return DEVICE_CLASS_ILLUMINANCE @property - def state(self): + def state(self) -> float: """Return the state.""" + if hasattr(self._device, 'averageIllumination'): + return self._device.averageIllumination + return self._device.illumination @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return 'lx' -class HomematicipLightSensor(HomematicipIlluminanceSensor): - """Represenation of a HomematicIP Illuminance device.""" - - @property - def state(self): - """Return the state.""" - return self._device.averageIllumination - - class HomematicipPowerSensor(HomematicipGenericDevice): """Represenation of a HomematicIP power measuring device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the device.""" super().__init__(home, device, 'Power') @property - def device_class(self): + def device_class(self) -> str: """Return the device class of the sensor.""" return DEVICE_CLASS_POWER @property - def state(self): + def state(self) -> float: """Represenation of the HomematicIP power comsumption value.""" return self._device.currentPowerConsumption @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return POWER_WATT @@ -256,17 +253,17 @@ class HomematicipPowerSensor(HomematicipGenericDevice): class HomematicipWindspeedSensor(HomematicipGenericDevice): """Represenation of a HomematicIP wind speed sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the device.""" super().__init__(home, device, 'Windspeed') @property - def state(self): + def state(self) -> float: """Represenation of the HomematicIP wind speed value.""" return self._device.windSpeed @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return 'km/h' @@ -288,22 +285,22 @@ class HomematicipWindspeedSensor(HomematicipGenericDevice): class HomematicipTodayRainSensor(HomematicipGenericDevice): """Represenation of a HomematicIP rain counter of a day sensor.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the device.""" super().__init__(home, device, 'Today Rain') @property - def state(self): + def state(self) -> float: """Represenation of the HomematicIP todays rain value.""" return round(self._device.todayRainCounter, 2) @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return 'mm' -def _get_wind_direction(wind_direction_degree): +def _get_wind_direction(wind_direction_degree: float) -> str: """Convert wind direction degree to named direction.""" if 11.25 <= wind_direction_degree < 33.75: return 'NNE' diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index b96e0c4cf4d..7b87f6c740e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -1,7 +1,16 @@ """Support for HomematicIP Cloud switches.""" import logging +from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring, AsyncMultiIOBox, + AsyncOpenCollector8Module, AsyncPlugableSwitch, + AsyncPlugableSwitchMeasuring) +from homematicip.aio.group import AsyncSwitchingGroup +from homematicip.aio.home import AsyncHome + from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice from .device import ATTR_GROUP_MEMBER_UNREACHABLE @@ -15,19 +24,9 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP switch from a config entry.""" - from homematicip.aio.device import ( - AsyncPlugableSwitch, - AsyncPlugableSwitchMeasuring, - AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring, - AsyncOpenCollector8Module, - AsyncMultiIOBox, - ) - - from homematicip.aio.group import AsyncSwitchingGroup - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: @@ -60,12 +59,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): """representation of a HomematicIP Cloud switch device.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the switch device.""" super().__init__(home, device) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.on @@ -81,18 +80,18 @@ class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchDevice): """representation of a HomematicIP switching group.""" - def __init__(self, home, device, post='Group'): + def __init__(self, home: AsyncHome, device, post: str = 'Group') -> None: """Initialize switching group.""" device.modelType = 'HmIP-{}'.format(post) super().__init__(home, device, post) @property - def is_on(self): + def is_on(self) -> bool: """Return true if group is on.""" return self._device.on @property - def available(self): + def available(self) -> bool: """Switch-Group available.""" # A switch-group must be available, and should not be affected by the # individual availability of group members. @@ -121,12 +120,12 @@ class HomematicipSwitchMeasuring(HomematicipSwitch): """Representation of a HomematicIP measuring switch device.""" @property - def current_power_w(self): + def current_power_w(self) -> float: """Return the current power usage in W.""" return self._device.currentPowerConsumption @property - def today_energy_kwh(self): + def today_energy_kwh(self) -> int: """Return the today total energy usage in kWh.""" if self._device.energyCounter is None: return 0 @@ -136,19 +135,19 @@ class HomematicipSwitchMeasuring(HomematicipSwitch): class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice): """Representation of a HomematicIP Cloud multi switch device.""" - def __init__(self, home, device, channel): + def __init__(self, home: AsyncHome, device, channel: int): """Initialize the multi switch device.""" self.channel = channel super().__init__(home, device, 'Channel{}'.format(channel)) @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return "{}_{}_{}".format(self.__class__.__name__, self.post, self._device.id) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.functionalChannels[self.channel].on diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 74b302b18fc..b97948b2d9f 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -2,8 +2,14 @@ """Support for HomematicIP Cloud weather devices.""" import logging +from homematicip.aio.device import ( + AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) +from homematicip.aio.home import AsyncHome + from homeassistant.components.weather import WeatherEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice @@ -16,12 +22,9 @@ async def async_setup_platform( pass -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities) -> None: """Set up the HomematicIP weather sensor from a config entry.""" - from homematicip.aio.device import ( - AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro, - ) - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: @@ -37,42 +40,42 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity): """representation of a HomematicIP Cloud weather sensor plus & basic.""" - def __init__(self, home, device): + def __init__(self, home: AsyncHome, device) -> None: """Initialize the weather sensor.""" super().__init__(home, device) @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._device.label @property - def temperature(self): + def temperature(self) -> float: """Return the platform temperature.""" return self._device.actualTemperature @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def humidity(self): + def humidity(self) -> int: """Return the humidity.""" return self._device.humidity @property - def wind_speed(self): + def wind_speed(self) -> float: """Return the wind speed.""" return self._device.windSpeed @property - def attribution(self): + def attribution(self) -> str: """Return the attribution.""" return "Powered by Homematic IP" @property - def condition(self): + def condition(self) -> str: """Return the current condition.""" if hasattr(self._device, "raining") and self._device.raining: return 'rainy' @@ -87,6 +90,6 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor): """representation of a HomematicIP weather sensor pro.""" @property - def wind_bearing(self): + def wind_bearing(self) -> float: """Return the wind bearing.""" return self._device.windDirection diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index df19f67a876..5a07b094e24 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -53,6 +53,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if region == 'us': return _setup_us(username, password, config, add_entities) + _LOGGER.warning( + "The honeywell component is deprecated for EU (i.e. non-US) systems, " + "this functionality will be removed in version 0.96.") + _LOGGER.warning( + "Please switch to the evohome component, " + "see: https://home-assistant.io/components/evohome") + return _setup_round(username, password, config, add_entities) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 2fcaa266b85..5ab2b39baed 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -1,5 +1,6 @@ """HTML5 Push Messaging notification service.""" -import datetime +from datetime import datetime, timedelta + from functools import partial import json import logging @@ -39,14 +40,12 @@ ATTR_VAPID_EMAIL = 'vapid_email' def gcm_api_deprecated(value): """Warn user that GCM API config is deprecated.""" - if not value: - return value - - _LOGGER.warning( - "Configuring html5_push_notifications via the GCM api" - " has been deprecated and will stop working after April 11," - " 2019. Use the VAPID configuration instead. For instructions," - " see https://www.home-assistant.io/components/notify.html5/") + if value: + _LOGGER.warning( + "Configuring html5_push_notifications via the GCM api" + " has been deprecated and will stop working after April 11," + " 2019. Use the VAPID configuration instead. For instructions," + " see https://www.home-assistant.io/components/notify.html5/") return value @@ -75,6 +74,10 @@ ATTR_ACTIONS = 'actions' ATTR_TYPE = 'type' ATTR_URL = 'url' ATTR_DISMISS = 'dismiss' +ATTR_PRIORITY = 'priority' +DEFAULT_PRIORITY = 'normal' +ATTR_TTL = 'ttl' +DEFAULT_TTL = 86400 ATTR_JWT = 'jwt' @@ -193,7 +196,6 @@ class HTML5PushRegistrationView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) - try: data = REGISTER_SCHEMA(data) except vol.Invalid as ex: @@ -373,7 +375,7 @@ class HTML5NotificationService(BaseNotificationService): """Initialize the service.""" self._gcm_key = gcm_key self._vapid_prv = vapid_prv - self._vapid_claims = {"sub": "mailto:{}".format(vapid_email)} + self._vapid_email = vapid_email self.registrations = registrations self.registrations_json_path = json_path @@ -425,7 +427,6 @@ class HTML5NotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" tag = str(uuid.uuid4()) - payload = { 'badge': '/static/images/notification-badge.png', 'body': message, @@ -459,13 +460,14 @@ class HTML5NotificationService(BaseNotificationService): def _push_message(self, payload, **kwargs): """Send the message.""" - import jwt - from pywebpush import WebPusher, webpush + from pywebpush import WebPusher timestamp = int(time.time()) - + ttl = int(kwargs.get(ATTR_TTL, DEFAULT_TTL)) + priority = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) + if priority not in ['normal', 'high']: + priority = DEFAULT_PRIORITY payload['timestamp'] = (timestamp*1000) # Javascript ms since epoch - targets = kwargs.get(ATTR_TARGET) if not targets: @@ -473,26 +475,37 @@ class HTML5NotificationService(BaseNotificationService): for target in list(targets): info = self.registrations.get(target) - if info is None: + try: + info = REGISTER_SCHEMA(info) + except vol.Invalid: _LOGGER.error("%s is not a valid HTML5 push notification" " target", target) continue - - jwt_exp = (datetime.datetime.fromtimestamp(timestamp) + - datetime.timedelta(days=JWT_VALID_DAYS)) + payload[ATTR_DATA][ATTR_JWT] = add_jwt( + timestamp, target, payload[ATTR_TAG], + info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]) + import jwt jwt_secret = info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] + jwt_exp = (datetime.fromtimestamp(timestamp) + + timedelta(days=JWT_VALID_DAYS)) jwt_claims = {'exp': jwt_exp, 'nbf': timestamp, 'iat': timestamp, ATTR_TARGET: target, ATTR_TAG: payload[ATTR_TAG]} jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') payload[ATTR_DATA][ATTR_JWT] = jwt_token - - if self._vapid_prv and self._vapid_claims: - response = webpush( - info[ATTR_SUBSCRIPTION], - json.dumps(payload), - vapid_private_key=self._vapid_prv, - vapid_claims=self._vapid_claims + webpusher = WebPusher(info[ATTR_SUBSCRIPTION]) + if self._vapid_prv and self._vapid_email: + vapid_headers = create_vapid_headers( + self._vapid_email, info[ATTR_SUBSCRIPTION], + self._vapid_prv) + vapid_headers.update({ + 'urgency': priority, + 'priority': priority + }) + response = webpusher.send( + data=json.dumps(payload), + headers=vapid_headers, + ttl=ttl ) else: # Only pass the gcm key if we're actually using GCM @@ -501,8 +514,8 @@ class HTML5NotificationService(BaseNotificationService): if 'googleapis.com' \ in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ else None - response = WebPusher(info[ATTR_SUBSCRIPTION]).send( - json.dumps(payload), gcm_key=gcm_key, ttl='86400' + response = webpusher.send( + json.dumps(payload), gcm_key=gcm_key, ttl=ttl ) if response.status_code == 410: @@ -514,3 +527,33 @@ class HTML5NotificationService(BaseNotificationService): _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") + + +def add_jwt(timestamp, target, tag, jwt_secret): + """Create JWT json to put into payload.""" + import jwt + jwt_exp = (datetime.fromtimestamp(timestamp) + + timedelta(days=JWT_VALID_DAYS)) + jwt_claims = {'exp': jwt_exp, 'nbf': timestamp, + 'iat': timestamp, ATTR_TARGET: target, + ATTR_TAG: tag} + return jwt.encode(jwt_claims, jwt_secret).decode('utf-8') + + +def create_vapid_headers(vapid_email, subscription_info, vapid_private_key): + """Create encrypted headers to send to WebPusher.""" + from py_vapid import Vapid + try: + from urllib.parse import urlparse + except ImportError: # pragma: no cover + from urlparse import urlparse + if (vapid_email and vapid_private_key and + ATTR_ENDPOINT in subscription_info): + url = urlparse(subscription_info.get(ATTR_ENDPOINT)) + vapid_claims = { + 'sub': 'mailto:{}'.format(vapid_email), + 'aud': "{}://{}".format(url.scheme, url.netloc) + } + vapid = Vapid.from_string(private_key=vapid_private_key) + return vapid.sign(vapid_claims) + return None diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index d6c49f5e255..552bfb90703 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_URL -from ..huawei_lte import DATA_KEY, RouterData +from . import DATA_KEY, RouterData PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_URL): cv.url, diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 6394140c07f..2222c1333dd 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -9,7 +9,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_RECIPIENT, CONF_URL import homeassistant.helpers.config_validation as cv -from ..huawei_lte import DATA_KEY +from . import DATA_KEY _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 42bd1f16271..5dac3c2c787 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -from ..huawei_lte import DATA_KEY, RouterData +from . import DATA_KEY, RouterData _LOGGER = logging.getLogger(__name__) @@ -62,37 +62,37 @@ SENSOR_META = { name="RSRQ", # http://www.lte-anbieter.info/technik/rsrq.php icon=lambda x: - x >= -5 and "mdi:signal-cellular-3" - or x >= -8 and "mdi:signal-cellular-2" - or x >= -11 and "mdi:signal-cellular-1" - or "mdi:signal-cellular-outline" + (x is None or x < -11) and "mdi:signal-cellular-outline" + or x < -8 and "mdi:signal-cellular-1" + or x < -5 and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3" ), "device_signal.rsrp": dict( name="RSRP", # http://www.lte-anbieter.info/technik/rsrp.php icon=lambda x: - x >= -80 and "mdi:signal-cellular-3" - or x >= -95 and "mdi:signal-cellular-2" - or x >= -110 and "mdi:signal-cellular-1" - or "mdi:signal-cellular-outline" + (x is None or x < -110) and "mdi:signal-cellular-outline" + or x < -95 and "mdi:signal-cellular-1" + or x < -80 and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3" ), "device_signal.rssi": dict( name="RSSI", # https://eyesaas.com/wi-fi-signal-strength/ icon=lambda x: - x >= -60 and "mdi:signal-cellular-3" - or x >= -70 and "mdi:signal-cellular-2" - or x >= -80 and "mdi:signal-cellular-1" - or "mdi:signal-cellular-outline" + (x is None or x < -80) and "mdi:signal-cellular-outline" + or x < -70 and "mdi:signal-cellular-1" + or x < -60 and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3" ), "device_signal.sinr": dict( name="SINR", # http://www.lte-anbieter.info/technik/sinr.php icon=lambda x: - x >= 10 and "mdi:signal-cellular-3" - or x >= 5 and "mdi:signal-cellular-2" - or x >= 0 and "mdi:signal-cellular-1" - or "mdi:signal-cellular-outline" + (x is None or x < 0) and "mdi:signal-cellular-outline" + or x < 5 and "mdi:signal-cellular-1" + or x < 10 and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3" ), } diff --git a/homeassistant/components/hue/.translations/cs.json b/homeassistant/components/hue/.translations/cs.json index 35c423b1a03..82be2e7fb00 100644 --- a/homeassistant/components/hue/.translations/cs.json +++ b/homeassistant/components/hue/.translations/cs.json @@ -24,6 +24,6 @@ "title": "P\u0159ipojit Hub" } }, - "title": "Philips Hue p\u0159emost\u011bn\u00ed" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index d61a0aa7e89..522e8fc0ad9 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -1,6 +1,6 @@ """Constants for the Hue component.""" import logging -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) DOMAIN = "hue" API_NUPNP = 'https://www.meethue.com/api/nupnp' diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index a79b0e3ee23..c517184b62a 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -312,6 +312,8 @@ class HueLight(Light): @property def effect_list(self): """Return the list of supported effects.""" + if self.is_osram: + return [EFFECT_RANDOM] return [EFFECT_COLORLOOP, EFFECT_RANDOM] @property @@ -371,15 +373,15 @@ class HueLight(Light): else: command['alert'] = 'none' - effect = kwargs.get(ATTR_EFFECT) - - if effect == EFFECT_COLORLOOP: - command['effect'] = 'colorloop' - elif effect == EFFECT_RANDOM: - command['hue'] = random.randrange(0, 65535) - command['sat'] = random.randrange(150, 254) - elif self.is_philips: - command['effect'] = 'none' + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect == EFFECT_COLORLOOP: + command['effect'] = 'colorloop' + elif effect == EFFECT_RANDOM: + command['hue'] = random.randrange(0, 65535) + command['sat'] = random.randrange(150, 254) + else: + command['effect'] = 'none' if self.is_group: await self.light.set_action(**command) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 1d6fa2d34b4..9dca6e31b1d 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -13,7 +13,7 @@ from homeassistant.util.dt import utcnow CURRENT_SENSORS = 'current_sensors' -SENSOR_MANAGER = 'sensor_manager' +SENSOR_MANAGER_FORMAT = '{}_sensor_manager' _LOGGER = logging.getLogger(__name__) @@ -32,10 +32,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities, bridge = hass.data[hue.DOMAIN][config_entry.data['host']] hass.data[hue.DOMAIN].setdefault(CURRENT_SENSORS, {}) - manager = hass.data[hue.DOMAIN].get(SENSOR_MANAGER) + sm_key = SENSOR_MANAGER_FORMAT.format(config_entry.data['host']) + manager = hass.data[hue.DOMAIN].get(sm_key) if manager is None: manager = SensorManager(hass, bridge) - hass.data[hue.DOMAIN][SENSOR_MANAGER] = manager + hass.data[hue.DOMAIN][sm_key] = manager manager.register_component(binary, async_add_entities) await manager.start() diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json index fc804bba46c..4d09e697150 100644 --- a/homeassistant/components/ifttt/.translations/es.json +++ b/homeassistant/components/ifttt/.translations/es.json @@ -5,7 +5,7 @@ "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." }, "create_entry": { - "default": "Para enviar eventos a Home Assistant, deber\u00e1 usar la acci\u00f3n \"Make a web request\" del [applet IFTTT Webhook] ( {applet_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json\n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + "default": "Para enviar eventos a Home Assistant debes usar la acci\u00f3n \"Make a web request\" del [applet IFTTT Webhook]({applet_url}).\n\nCompleta la siguiente informaci\u00f3n: \n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n- Tipo de contenido: application/json\n\nConsulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." }, "step": { "user": { diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index bbcd4ab9389..25d0317078f 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -3,7 +3,7 @@ "name": "Ihc", "documentation": "https://www.home-assistant.io/components/ihc", "requirements": [ - "defusedxml==0.5.0", + "defusedxml==0.6.0", "ihcsdk==2.3.0" ], "dependencies": [], diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py new file mode 100644 index 00000000000..edff8c8299f --- /dev/null +++ b/homeassistant/components/incomfort/__init__.py @@ -0,0 +1,50 @@ +"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" +import logging + +import voluptuous as vol +from incomfortclient import Gateway as InComfortGateway + +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'incomfort' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Inclusive(CONF_USERNAME, 'credentials'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'credentials'): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, hass_config): + """Create an Intergas InComfort/Intouch system.""" + incomfort_data = hass.data[DOMAIN] = {} + + credentials = dict(hass_config[DOMAIN]) + hostname = credentials.pop(CONF_HOST) + + try: + client = incomfort_data['client'] = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) + ) + + heater = incomfort_data['heater'] = list(await client.heaters)[0] + await heater.update() + + except AssertionError: # assert response.status == HTTP_OK + _LOGGER.warning( + "Setup failed, check your configuration.", + exc_info=True) + return False + + hass.async_create_task(async_load_platform( + hass, 'water_heater', DOMAIN, {}, hass_config)) + + return True diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json new file mode 100644 index 00000000000..028a741a673 --- /dev/null +++ b/homeassistant/components/incomfort/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "incomfort", + "name": "Intergas InComfort/Intouch Lan2RF gateway", + "documentation": "https://www.home-assistant.io/components/incomfort", + "requirements": [ + "incomfort-client==0.2.8" + ], + "dependencies": [], + "codeowners": [ + "@zxdavb" + ] +} diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py new file mode 100644 index 00000000000..9223902f5a3 --- /dev/null +++ b/homeassistant/components/incomfort/water_heater.py @@ -0,0 +1,94 @@ +"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" +import asyncio +import logging + +from homeassistant.components.water_heater import WaterHeaterDevice +from homeassistant.const import TEMP_CELSIUS + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +HEATER_SUPPORT_FLAGS = 0 + +HEATER_MAX_TEMP = 80.0 +HEATER_MIN_TEMP = 30.0 + +HEATER_NAME = 'Boiler' +HEATER_ATTRS = [ + 'display_code', 'display_text', 'fault_code', 'is_burning', 'is_failed', + 'is_pumping', 'is_tapping', 'heater_temp', 'tap_temp', 'pressure'] + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up an InComfort/Intouch water_heater device.""" + client = hass.data[DOMAIN]['client'] + heater = hass.data[DOMAIN]['heater'] + + async_add_entities([ + IncomfortWaterHeater(client, heater)], update_before_add=True) + + +class IncomfortWaterHeater(WaterHeaterDevice): + """Representation of an InComfort/Intouch water_heater device.""" + + def __init__(self, client, heater): + """Initialize the water_heater device.""" + self._client = client + self._heater = heater + + @property + def name(self): + """Return the name of the water_heater device.""" + return HEATER_NAME + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + state = {k: self._heater.status[k] + for k in self._heater.status if k in HEATER_ATTRS} + return state + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._heater.is_tapping: + return self._heater.tap_temp + return self._heater.heater_temp + + @property + def min_temp(self): + """Return max valid temperature that can be set.""" + return HEATER_MIN_TEMP + + @property + def max_temp(self): + """Return max valid temperature that can be set.""" + return HEATER_MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self): + """Return the list of supported features.""" + return HEATER_SUPPORT_FLAGS + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._heater.is_failed: + return "Failed ({})".format(self._heater.fault_code) + + return self._heater.display_text + + async def async_update(self): + """Get the latest state data from the gateway.""" + try: + await self._heater.update() + + except (AssertionError, asyncio.TimeoutError) as err: + _LOGGER.warning("Update failed, message: %s", err) diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index bf2ba1b8ecc..0289dc63d88 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -321,12 +321,12 @@ class InfluxThread(threading.Thread): _LOGGER.debug("Wrote %d events", len(json)) break - except (exceptions.InfluxDBClientError, IOError): + except (exceptions.InfluxDBClientError, IOError) as err: if retry < self.max_tries: time.sleep(RETRY_DELAY) else: if not self.write_errors: - _LOGGER.exception("Write error") + _LOGGER.error("Write error: %s", err) self.write_errors += len(json) def run(self): diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 34faffd2028..af0a28aa34a 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -20,6 +20,8 @@ CONF_HAS_DATE = 'has_date' CONF_HAS_TIME = 'has_time' CONF_INITIAL = 'initial' +DEFAULT_VALUE = '1970-01-01 00:00:00' + ATTR_DATE = 'date' ATTR_TIME = 'time' @@ -120,13 +122,18 @@ class InputDatetime(RestoreEntity): if old_state is not None: restore_val = old_state.state - if restore_val is not None: - if not self.has_date: - self._current_datetime = dt_util.parse_time(restore_val) - elif not self.has_time: - self._current_datetime = dt_util.parse_date(restore_val) - else: - self._current_datetime = dt_util.parse_datetime(restore_val) + if not self.has_date: + if not restore_val: + restore_val = DEFAULT_VALUE.split()[1] + self._current_datetime = dt_util.parse_time(restore_val) + elif not self.has_time: + if not restore_val: + restore_val = DEFAULT_VALUE.split()[0] + self._current_datetime = dt_util.parse_date(restore_val) + else: + if not restore_val: + restore_val = DEFAULT_VALUE + self._current_datetime = dt_util.parse_datetime(restore_val) @property def should_poll(self): diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py new file mode 100644 index 00000000000..23803d7f17d --- /dev/null +++ b/homeassistant/components/iqvia/__init__.py @@ -0,0 +1,220 @@ +"""Support for IQVIA.""" +import asyncio +from datetime import timedelta +import logging + +from pyiqvia import Client +from pyiqvia.errors import IQVIAError, InvalidZipError + +import voluptuous as vol + +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +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 +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.decorator import Registry + +from .const import ( + DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS, TOPIC_DATA_UPDATE, + TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, + TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, TYPE_ASTHMA_FORECAST, + TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, + TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY) + +_LOGGER = logging.getLogger(__name__) + + +CONF_ZIP_CODE = 'zip_code' + +DATA_CONFIG = 'config' + +DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +FETCHER_MAPPING = { + (TYPE_ALLERGY_FORECAST,): (TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK), + (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): (TYPE_ALLERGY_INDEX,), + (TYPE_ASTHMA_FORECAST,): (TYPE_ASTHMA_FORECAST,), + (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): (TYPE_ASTHMA_INDEX,), + (TYPE_DISEASE_FORECAST,): (TYPE_DISEASE_FORECAST,), + (TYPE_DISEASE_TODAY,): (TYPE_DISEASE_INDEX,), +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ZIP_CODE): str, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the IQVIA component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + conf = config[DOMAIN] + + websession = aiohttp_client.async_get_clientsession(hass) + + try: + iqvia = IQVIAData( + Client(conf[CONF_ZIP_CODE], websession), + conf[CONF_MONITORED_CONDITIONS]) + await iqvia.async_update() + except IQVIAError as err: + _LOGGER.error('Unable to set up IQVIA: %s', err) + return False + + hass.data[DOMAIN][DATA_CLIENT] = iqvia + + hass.async_create_task( + async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + + async def refresh(event_time): + """Refresh IQVIA data.""" + _LOGGER.debug('Updating IQVIA data') + await iqvia.async_update() + async_dispatcher_send(hass, TOPIC_DATA_UPDATE) + + hass.data[DOMAIN][DATA_LISTENER] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL) + + return True + + +class IQVIAData: + """Define a data object to retrieve info from IQVIA.""" + + def __init__(self, client, sensor_types): + """Initialize.""" + self._client = client + self.data = {} + self.sensor_types = sensor_types + self.zip_code = client.zip_code + + self.fetchers = Registry() + self.fetchers.register(TYPE_ALLERGY_FORECAST)( + self._client.allergens.extended) + self.fetchers.register(TYPE_ALLERGY_OUTLOOK)( + self._client.allergens.outlook) + self.fetchers.register(TYPE_ALLERGY_INDEX)( + self._client.allergens.current) + self.fetchers.register(TYPE_ASTHMA_FORECAST)( + self._client.asthma.extended) + self.fetchers.register(TYPE_ASTHMA_INDEX)(self._client.asthma.current) + self.fetchers.register(TYPE_DISEASE_FORECAST)( + self._client.disease.extended) + self.fetchers.register(TYPE_DISEASE_INDEX)( + self._client.disease.current) + + async def async_update(self): + """Update IQVIA data.""" + tasks = {} + + for conditions, fetcher_types in FETCHER_MAPPING.items(): + if not any(c in self.sensor_types for c in conditions): + continue + + for fetcher_type in fetcher_types: + tasks[fetcher_type] = self.fetchers[fetcher_type]() + + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + + # IQVIA sites require a bit more complicated error handling, given that + # they sometimes have parts (but not the whole thing) go down: + # 1. If `InvalidZipError` is thrown, quit everything immediately. + # 2. If a single request throws any other error, try the others. + for key, result in zip(tasks, results): + if isinstance(result, InvalidZipError): + _LOGGER.error("No data for ZIP: %s", self._client.zip_code) + self.data = {} + return + + if isinstance(result, IQVIAError): + _LOGGER.error('Unable to get %s data: %s', key, result) + self.data[key] = {} + continue + + _LOGGER.debug('Loaded new %s data', key) + self.data[key] = result + + +class IQVIAEntity(Entity): + """Define a base IQVIA entity.""" + + def __init__(self, iqvia, sensor_type, name, icon, zip_code): + """Initialize the sensor.""" + self._async_unsub_dispatcher_connect = None + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._icon = icon + self._iqvia = iqvia + self._name = name + self._state = None + self._type = sensor_type + self._zip_code = zip_code + + @property + def available(self): + """Return True if entity is available.""" + if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + return self._iqvia.data.get(TYPE_ALLERGY_INDEX) is not None + + if self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + return self._iqvia.data.get(TYPE_ASTHMA_INDEX) is not None + + if self._type == TYPE_DISEASE_TODAY: + return self._iqvia.data.get(TYPE_DISEASE_INDEX) is not None + + return self._iqvia.data.get(self._type) is not None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format(self._zip_code, self._type) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return 'index' + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_DATA_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py new file mode 100644 index 00000000000..025fa8a9505 --- /dev/null +++ b/homeassistant/components/iqvia/const.py @@ -0,0 +1,31 @@ +"""Define IQVIA constants.""" +DOMAIN = 'iqvia' + +DATA_CLIENT = 'client' +DATA_LISTENER = 'listener' + +TOPIC_DATA_UPDATE = 'data_update' + +TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted' +TYPE_ALLERGY_INDEX = 'allergy_index' +TYPE_ALLERGY_OUTLOOK = 'allergy_outlook' +TYPE_ALLERGY_TODAY = 'allergy_index_today' +TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow' +TYPE_ASTHMA_FORECAST = 'asthma_average_forecasted' +TYPE_ASTHMA_INDEX = 'asthma_index' +TYPE_ASTHMA_TODAY = 'asthma_index_today' +TYPE_ASTHMA_TOMORROW = 'asthma_index_tomorrow' +TYPE_DISEASE_FORECAST = 'disease_average_forecasted' +TYPE_DISEASE_INDEX = 'disease_index' +TYPE_DISEASE_TODAY = 'disease_index_today' + +SENSORS = { + TYPE_ALLERGY_FORECAST: ('Allergy Index: Forecasted Average', 'mdi:flower'), + TYPE_ALLERGY_TODAY: ('Allergy Index: Today', 'mdi:flower'), + TYPE_ALLERGY_TOMORROW: ('Allergy Index: Tomorrow', 'mdi:flower'), + TYPE_ASTHMA_TODAY: ('Asthma Index: Today', 'mdi:flower'), + TYPE_ASTHMA_TOMORROW: ('Asthma Index: Tomorrow', 'mdi:flower'), + TYPE_ASTHMA_FORECAST: ('Asthma Index: Forecasted Average', 'mdi:flower'), + TYPE_DISEASE_FORECAST: ('Cold & Flu: Forecasted Average', 'mdi:snowflake'), + TYPE_DISEASE_TODAY: ('Cold & Flu Index: Today', 'mdi:pill'), +} diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json new file mode 100644 index 00000000000..1757ffc2a22 --- /dev/null +++ b/homeassistant/components/iqvia/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iqvia", + "name": "IQVIA", + "documentation": "https://www.home-assistant.io/components/iqvia", + "requirements": [ + "numpy==1.16.3", + "pyiqvia==0.2.0" + ], + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py new file mode 100644 index 00000000000..b0b09c3f977 --- /dev/null +++ b/homeassistant/components/iqvia/sensor.py @@ -0,0 +1,189 @@ +"""Support for IQVIA sensors.""" +import logging +from statistics import mean + +import numpy as np + +from homeassistant.components.iqvia import ( + DATA_CLIENT, DOMAIN, SENSORS, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK, + TYPE_ALLERGY_INDEX, TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ASTHMA_FORECAST, TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY, + TYPE_ASTHMA_TOMORROW, TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, + TYPE_DISEASE_TODAY, IQVIAEntity) +from homeassistant.const import ATTR_STATE + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALLERGEN_AMOUNT = 'allergen_amount' +ATTR_ALLERGEN_GENUS = 'allergen_genus' +ATTR_ALLERGEN_NAME = 'allergen_name' +ATTR_ALLERGEN_TYPE = 'allergen_type' +ATTR_CITY = 'city' +ATTR_OUTLOOK = 'outlook' +ATTR_RATING = 'rating' +ATTR_SEASON = 'season' +ATTR_TREND = 'trend' +ATTR_ZIP_CODE = 'zip_code' + +RATING_MAPPING = [{ + 'label': 'Low', + 'minimum': 0.0, + 'maximum': 2.4 +}, { + 'label': 'Low/Medium', + 'minimum': 2.5, + 'maximum': 4.8 +}, { + 'label': 'Medium', + 'minimum': 4.9, + 'maximum': 7.2 +}, { + 'label': 'Medium/High', + 'minimum': 7.3, + 'maximum': 9.6 +}, { + 'label': 'High', + 'minimum': 9.7, + 'maximum': 12 +}] + +TREND_FLAT = 'Flat' +TREND_INCREASING = 'Increasing' +TREND_SUBSIDING = 'Subsiding' + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + iqvia = hass.data[DOMAIN][DATA_CLIENT] + + sensor_class_mapping = { + TYPE_ALLERGY_FORECAST: ForecastSensor, + TYPE_ALLERGY_TODAY: IndexSensor, + TYPE_ALLERGY_TOMORROW: IndexSensor, + TYPE_ASTHMA_FORECAST: ForecastSensor, + TYPE_ASTHMA_TODAY: IndexSensor, + TYPE_ASTHMA_TOMORROW: IndexSensor, + TYPE_DISEASE_FORECAST: ForecastSensor, + TYPE_DISEASE_TODAY: IndexSensor, + } + + sensors = [] + for sensor_type in iqvia.sensor_types: + klass = sensor_class_mapping[sensor_type] + name, icon = SENSORS[sensor_type] + sensors.append(klass(iqvia, sensor_type, name, icon, iqvia.zip_code)) + + async_add_entities(sensors, True) + + +def calculate_trend(indices): + """Calculate the "moving average" of a set of indices.""" + index_range = np.arange(0, len(indices)) + index_array = np.array(indices) + linear_fit = np.polyfit(index_range, index_array, 1) + slope = round(linear_fit[0], 2) + + if slope > 0: + return TREND_INCREASING + + if slope < 0: + return TREND_SUBSIDING + + return TREND_FLAT + + +class ForecastSensor(IQVIAEntity): + """Define sensor related to forecast data.""" + + async def async_update(self): + """Update the sensor.""" + if not self._iqvia.data: + return + + data = self._iqvia.data[self._type].get('Location') + if not data: + return + + indices = [p['Index'] for p in data['periods']] + average = round(mean(indices), 1) + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= average <= i['maximum'] + ] + + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_TREND: calculate_trend(indices), + ATTR_ZIP_CODE: data['ZIP'] + }) + + if self._type == TYPE_ALLERGY_FORECAST: + outlook = self._iqvia.data[TYPE_ALLERGY_OUTLOOK] + self._attrs[ATTR_OUTLOOK] = outlook.get('Outlook') + self._attrs[ATTR_SEASON] = outlook.get('Season') + + self._state = average + + +class IndexSensor(IQVIAEntity): + """Define sensor related to indices.""" + + async def async_update(self): + """Update the sensor.""" + if not self._iqvia.data: + return + + data = {} + if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + data = self._iqvia.data[TYPE_ALLERGY_INDEX].get('Location') + elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + data = self._iqvia.data[TYPE_ASTHMA_INDEX].get('Location') + elif self._type == TYPE_DISEASE_TODAY: + data = self._iqvia.data[TYPE_DISEASE_INDEX].get('Location') + + if not data: + return + + key = self._type.split('_')[-1].title() + [period] = [p for p in data['periods'] if p['Type'] == key] + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= period['Index'] <= i['maximum'] + ] + + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_ZIP_CODE: data['ZIP'] + }) + + if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + for idx, attrs in enumerate(period['Triggers']): + index = idx + 1 + self._attrs.update({ + '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index): + attrs['Genus'], + '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): + attrs['Name'], + '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index): + attrs['PlantType'], + }) + elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + for idx, attrs in enumerate(period['Triggers']): + index = idx + 1 + self._attrs.update({ + '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): + attrs['Name'], + '{0}_{1}'.format(ATTR_ALLERGEN_AMOUNT, index): + attrs['PPM'], + }) + elif self._type == TYPE_DISEASE_TODAY: + for attrs in period['Triggers']: + self._attrs['{0}_index'.format( + attrs['Name'].lower())] = attrs['Index'] + + self._state = period['Index'] diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 418b6ffa89d..7e7fb1430cc 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -1,6 +1,8 @@ """Support for LCN devices.""" import logging +import pypck +from pypck.connection import PchkConnectionManager import voluptuous as vol from homeassistant.const import ( @@ -149,9 +151,6 @@ def get_connection(connections, connection_id=None): async def async_setup(hass, config): """Set up the LCN component.""" - import pypck - from pypck.connection import PchkConnectionManager - hass.data[DATA_LCN] = {} conf_connections = config[DOMAIN][CONF_CONNECTIONS] @@ -201,7 +200,6 @@ class LcnDevice(Entity): def __init__(self, config, address_connection): """Initialize the LCN device.""" - import pypck self.pypck = pypck self.config = config self.address_connection = address_connection diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index ec37d3e5128..a59494023bb 100755 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -1,4 +1,6 @@ """Support for LCN binary sensors.""" +import pypck + from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.const import CONF_ADDRESS @@ -13,8 +15,6 @@ async def async_setup_platform(hass, hass_config, async_add_entities, if discovery_info is None: return - import pypck - devices = [] for config in discovery_info: address, connection_id = config[CONF_ADDRESS] @@ -50,9 +50,8 @@ class LcnRegulatorLockSensor(LcnDevice, BinarySensorDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.setpoint_variable)) + await self.address_connection.activate_status_request_handler( + self.setpoint_variable) @property def is_on(self): @@ -84,9 +83,8 @@ class LcnBinarySensor(LcnDevice, BinarySensorDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.bin_sensor_port)) + await self.address_connection.activate_status_request_handler( + self.bin_sensor_port) @property def is_on(self): @@ -115,9 +113,8 @@ class LcnLockKeysSensor(LcnDevice, BinarySensorDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.source)) + await self.address_connection.activate_status_request_handler( + self.source) @property def is_on(self): diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 7123f2d5d0a..d07fa09c189 100755 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -1,4 +1,6 @@ """Support for LCN covers.""" +import pypck + from homeassistant.components.cover import CoverDevice from homeassistant.const import CONF_ADDRESS @@ -12,8 +14,6 @@ async def async_setup_platform(hass, hass_config, async_add_entities, if discovery_info is None: return - import pypck - devices = [] for config in discovery_info: address, connection_id = config[CONF_ADDRESS] @@ -43,9 +43,8 @@ class LcnCover(LcnDevice, CoverDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.motor)) + await self.address_connection.activate_status_request_handler( + self.motor) @property def is_closed(self): diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 653873ba78a..49cdff5de49 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -1,4 +1,6 @@ """Support for LCN lights.""" +import pypck + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, Light) @@ -16,8 +18,6 @@ async def async_setup_platform( if discovery_info is None: return - import pypck - devices = [] for config in discovery_info: address, connection_id = config[CONF_ADDRESS] @@ -56,9 +56,8 @@ class LcnOutputLight(LcnDevice, Light): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.output)) + await self.address_connection.activate_status_request_handler( + self.output) @property def supported_features(self): @@ -138,9 +137,8 @@ class LcnRelayLight(LcnDevice, Light): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.output)) + await self.address_connection.activate_status_request_handler( + self.output) @property def is_on(self): diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 48ac8c7266c..38b17c80793 100755 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,4 +1,6 @@ """Support for LCN sensors.""" +import pypck + from homeassistant.const import CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT from . import LcnDevice, get_connection @@ -13,8 +15,6 @@ async def async_setup_platform(hass, hass_config, async_add_entities, if discovery_info is None: return - import pypck - devices = [] for config in discovery_info: address, connection_id = config[CONF_ADDRESS] @@ -50,9 +50,8 @@ class LcnVariableSensor(LcnDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.variable)) + await self.address_connection.activate_status_request_handler( + self.variable) @property def state(self): @@ -91,9 +90,8 @@ class LcnLedLogicSensor(LcnDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.source)) + await self.address_connection.activate_status_request_handler( + self.source) @property def state(self): diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 48ae579fbcd..e5a8484e271 100755 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -1,4 +1,6 @@ """Support for LCN switches.""" +import pypck + from homeassistant.components.switch import SwitchDevice from homeassistant.const import CONF_ADDRESS @@ -12,8 +14,6 @@ async def async_setup_platform(hass, hass_config, async_add_entities, if discovery_info is None: return - import pypck - devices = [] for config in discovery_info: address, connection_id = config[CONF_ADDRESS] @@ -46,9 +46,8 @@ class LcnOutputSwitch(LcnDevice, SwitchDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.output)) + await self.address_connection.activate_status_request_handler( + self.output) @property def is_on(self): @@ -91,9 +90,8 @@ class LcnRelaySwitch(LcnDevice, SwitchDevice): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - self.hass.async_create_task( - self.address_connection.activate_status_request_handler( - self.output)) + await self.address_connection.activate_status_request_handler( + self.output) @property def is_on(self): diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 6b811b01f51..a8b1fd58afe 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/components/lifx", "requirements": [ "aiolifx==0.6.7", - "aiolifx_effects==0.2.1" + "aiolifx_effects==0.2.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/liveboxplaytv/manifest.json b/homeassistant/components/liveboxplaytv/manifest.json index 863507ada6c..3393022a363 100644 --- a/homeassistant/components/liveboxplaytv/manifest.json +++ b/homeassistant/components/liveboxplaytv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/components/liveboxplaytv", "requirements": [ "liveboxplaytv==2.0.2", - "pyteleloisirs==3.4" + "pyteleloisirs==3.5" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 444f4109e98..5f17716abbb 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -5,7 +5,7 @@ import os import voluptuous as vol -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, ATTR_ENTITY_ID from homeassistant.components.camera import ( Camera, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA) from homeassistant.components.camera.const import DOMAIN @@ -14,6 +14,7 @@ from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) CONF_FILE_PATH = 'file_path' +DATA_LOCAL_FILE = 'local_file_cameras' DEFAULT_NAME = 'Local File' SERVICE_UPDATE_FILE_PATH = 'local_file_update_file_path' @@ -29,13 +30,22 @@ CAMERA_SERVICE_UPDATE_FILE_PATH = CAMERA_SERVICE_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Camera that works with local files.""" + if DATA_LOCAL_FILE not in hass.data: + hass.data[DATA_LOCAL_FILE] = [] + file_path = config[CONF_FILE_PATH] camera = LocalFile(config[CONF_NAME], file_path) + hass.data[DATA_LOCAL_FILE].append(camera) def update_file_path_service(call): """Update the file path.""" file_path = call.data.get(CONF_FILE_PATH) - camera.update_file_path(file_path) + entity_ids = call.data.get(ATTR_ENTITY_ID) + cameras = hass.data[DATA_LOCAL_FILE] + + for camera in cameras: + if camera.entity_id in entity_ids: + camera.update_file_path(file_path) return True hass.services.register( diff --git a/homeassistant/components/locative/.translations/es.json b/homeassistant/components/locative/.translations/es.json index e48d33ba52d..c89a251b670 100644 --- a/homeassistant/components/locative/.translations/es.json +++ b/homeassistant/components/locative/.translations/es.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "\u00bfEst\u00e1s seguro que quieres configurar el webhook de Locative?", + "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de Locative?", "title": "Configurar el webhook de Locative" } }, diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index f21c55af28a..50e6f69b0bd 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -58,7 +58,7 @@ async def handle_webhook(hass, webhook_id, request): data = WEBHOOK_SCHEMA(dict(await request.post())) except vol.MultipleInvalid as error: return web.Response( - body=error.error_message, + text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY ) @@ -76,7 +76,7 @@ async def handle_webhook(hass, webhook_id, request): location_name ) return web.Response( - body='Setting location to {}'.format(location_name), + text='Setting location to {}'.format(location_name), status=HTTP_OK ) diff --git a/homeassistant/components/logi_circle/.translations/ca.json b/homeassistant/components/logi_circle/.translations/ca.json index f3c201d19fc..8e455023f2a 100644 --- a/homeassistant/components/logi_circle/.translations/ca.json +++ b/homeassistant/components/logi_circle/.translations/ca.json @@ -12,11 +12,11 @@ "error": { "auth_error": "Ha fallat l\u2019autoritzaci\u00f3 de l\u2019API.", "auth_timeout": "L\u2019autoritzaci\u00f3 ha expirat durant l'obtenci\u00f3 del testimoni d\u2019acc\u00e9s.", - "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Enviar" + "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia" }, "step": { "auth": { - "description": "V\u00e9s a l'enlla\u00e7 de sota i Accepta l'acc\u00e9s al teu compte de Logi Circle, despr\u00e9s, torna i prem Enviar (tamb\u00e9 a sota).\n\n[Enlla\u00e7]({authorization_url})", + "description": "V\u00e9s a l'enlla\u00e7 de sota i Accepta l'acc\u00e9s al teu compte de Logi Circle, despr\u00e9s, torna i prem Envia (tamb\u00e9 a sota).\n\n[Enlla\u00e7]({authorization_url})", "title": "Autenticaci\u00f3 amb Logi Circle" }, "user": { diff --git a/homeassistant/components/logi_circle/.translations/hu.json b/homeassistant/components/logi_circle/.translations/hu.json new file mode 100644 index 00000000000..8e304fa4ac9 --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "auth_error": "Az API enged\u00e9lyez\u00e9se sikertelen.", + "auth_timeout": "A hozz\u00e1f\u00e9r\u00e9si token k\u00e9r\u00e9sekor az enged\u00e9lyez\u00e9s lej\u00e1rt.", + "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot" + }, + "step": { + "user": { + "data": { + "flow_impl": "Szolg\u00e1ltat\u00f3" + }, + "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/pl.json b/homeassistant/components/logi_circle/.translations/pl.json new file mode 100644 index 00000000000..ab46b72fdac --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Logi Circle.", + "external_error": "Wyst\u0105pi\u0142 wyj\u0105tek zewn\u0119trzny.", + "external_setup": "Logi Circle pomy\u015blnie skonfigurowano.", + "no_flows": "Musisz skonfigurowa\u0107 Logi Circle, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcje](https://www.home-assistant.io/components/logi_circle/)." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Logi Circle." + }, + "error": { + "auth_error": "Autoryzacja API nie powiod\u0142a si\u0119.", + "auth_timeout": "Up\u0142yn\u0105\u0142 limit czasu \u017c\u0105dania tokena dost\u0119pu.", + "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij." + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Logi Circle, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", + "title": "Uwierzytelnienie Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Dostawca" + }, + "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Logi Circle.", + "title": "Dostawca uwierzytelnienia" + } + }, + "title": "Logi Circle" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/ru.json b/homeassistant/components/logi_circle/.translations/ru.json index 5e4d0890bfd..1e9c089828f 100644 --- a/homeassistant/components/logi_circle/.translations/ru.json +++ b/homeassistant/components/logi_circle/.translations/ru.json @@ -16,7 +16,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\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Logi Circle, \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\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c.", + "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\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Logi Circle, \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.", "title": "Logi Circle" }, "user": { diff --git a/homeassistant/components/logi_circle/.translations/sl.json b/homeassistant/components/logi_circle/.translations/sl.json new file mode 100644 index 00000000000..3906f96a39f --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Logi Circle.", + "external_error": "Izjema je pri\u0161la iz drugega toka.", + "external_setup": "Logi Circle uspe\u0161no konfiguriran iz drugega toka.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Logi Circle. [Preberite navodila](https://www.home-assistant.io/components/logi_circle/)." + }, + "create_entry": { + "default": "Uspe\u0161no overjen z Logi Circle-om." + }, + "error": { + "auth_error": "Odobritev API-ja ni uspela.", + "auth_timeout": "Pri zahtevi za dostopni \u017eeton je potekla \u010dasovna omejitev.", + "follow_link": "Prosimo, sledite povezavi in preverite pristnost, preden pritisnete Po\u0161lji" + }, + "step": { + "auth": { + "description": "Prosimo, sledite spodnji povezavi in Sprejmite dostop do va\u0161ega Logi Circle ra\u010duna, nato se vrnite in pritisnite Po\u0161lji spodaj. \n\n [Povezava] ( {authorization_url} )", + "title": "Overi z Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Logi Circle.", + "title": "Ponudnik overjanja" + } + }, + "title": "Logi Circle" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/sv.json b/homeassistant/components/logi_circle/.translations/sv.json new file mode 100644 index 00000000000..d7e1e1e251c --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/sv.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan endast konfigurera ett Logi Circle-konto." + }, + "create_entry": { + "default": "Autentiserad med Logi Circle." + }, + "error": { + "auth_error": "API autentiseringen misslyckades.", + "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera innan du trycker p\u00e5 Skicka." + }, + "step": { + "auth": { + "description": "V\u00e4nligen f\u00f6lj l\u00e4nken nedan och Godk\u00e4nn \u00e5tkomst till ditt Logic Circle-konto kom sedan tillbaka och tryck p\u00e5 Skicka nedan. \n\n [L\u00e4nk] ( {authorization_url} )", + "title": "Autentisera med Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Leverant\u00f6r" + }, + "description": "V\u00e4lj vilken autentiseringsleverant\u00f6r du vill autentisera med Logi Circle.", + "title": "Verifieringsleverant\u00f6r" + } + }, + "title": "Logi Circle" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 09baaa5ba0b..8d68a4c33b7 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -11,7 +11,7 @@ from homeassistant.const import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( - ATTRIBUTION, DOMAIN as LOGI_CIRCLE_DOMAIN, LED_MODE_KEY, + ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, LED_MODE_KEY, RECORDING_MODE_KEY, SIGNAL_LOGI_CIRCLE_RECONFIGURE, SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT) @@ -98,6 +98,19 @@ class LogiCam(Camera): """Logi Circle camera's support turning on and off ("soft" switch).""" return SUPPORT_ON_OFF + @property + def device_info(self): + """Return information about the device.""" + return { + 'name': self._camera.name, + 'identifiers': { + (LOGI_CIRCLE_DOMAIN, self._camera.id) + }, + 'model': self._camera.model_name, + 'sw_version': self._camera.firmware, + 'manufacturer': DEVICE_BRAND + } + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 6efd5065ba6..a66c68a694c 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -9,7 +9,8 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util.dt import as_local from .const import ( - ATTRIBUTION, DOMAIN as LOGI_CIRCLE_DOMAIN, LOGI_SENSORS as SENSOR_TYPES) + ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, + LOGI_SENSORS as SENSOR_TYPES) _LOGGER = logging.getLogger(__name__) @@ -66,6 +67,19 @@ class LogiSensor(Entity): """Return the state of the sensor.""" return self._state + @property + def device_info(self): + """Return information about the device.""" + return { + 'name': self._camera.name, + 'identifiers': { + (LOGI_CIRCLE_DOMAIN, self._camera.id) + }, + 'model': self._camera.model_name, + 'sw_version': self._camera.firmware, + 'manufacturer': DEVICE_BRAND + } + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/mailgun/.translations/es.json b/homeassistant/components/mailgun/.translations/es.json index 4a10ff69b69..4428d7e1868 100644 --- a/homeassistant/components/mailgun/.translations/es.json +++ b/homeassistant/components/mailgun/.translations/es.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.", + "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.", "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." }, "create_entry": { - "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Mailgun] ( {mailgun_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + "default": "Para enviar eventos a Home Assistant debes configurar los [Webhooks en Mailgun]({mailgun_url}). \n\n Completa la siguiente informaci\u00f3n: \n\n - URL: `{webhook_url}` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json \n\n Consulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." }, "step": { "user": { - "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?" + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?", + "title": "Configurar el Webhook de Mailgun" } }, "title": "Mailgun" diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index fd7e023fc91..730fe866a5d 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -3,7 +3,7 @@ "name": "Mastodon", "documentation": "https://www.home-assistant.io/components/mastodon", "requirements": [ - "Mastodon.py==1.3.1" + "Mastodon.py==1.4.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 98f03cd8fd0..e6456401ba4 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -8,8 +8,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA) -from homeassistant.const import ( - ATTR_ENTITY_ID) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 431e711951a..31086eab83d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -3,7 +3,7 @@ "name": "Media extractor", "documentation": "https://www.home-assistant.io/components/media_extractor", "requirements": [ - "youtube_dl==2019.04.07" + "youtube_dl==2019.04.30" ], "dependencies": [ "media_player" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 6efbdd7c3d4..ccfa968fa9a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -67,6 +67,16 @@ ENTITY_IMAGE_CACHE = { SCAN_INTERVAL = timedelta(seconds=10) +DEVICE_CLASS_TV = 'tv' +DEVICE_CLASS_SPEAKER = 'speaker' + +DEVICE_CLASSES = [ + DEVICE_CLASS_TV, + DEVICE_CLASS_SPEAKER, +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + # Service call validation schemas MEDIA_PLAYER_SCHEMA = vol.Schema({ ATTR_ENTITY_ID: cv.comp_entity_ids, @@ -325,6 +335,11 @@ class MediaPlayerDevice(Entity): """Image url of current playing media.""" return None + @property + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return False + @property def media_image_hash(self): """Hash value for media image.""" @@ -725,6 +740,9 @@ class MediaPlayerDevice(Entity): if self.state == STATE_OFF: return None + if self.media_image_remotely_accessible: + return self.media_image_url + image_hash = self.media_image_hash if image_hash is None: @@ -811,6 +829,14 @@ class MediaPlayerImageView(HomeAssistantView): if not authenticated: return web.Response(status=401) + if player.media_image_remotely_accessible: + url = player.media_image_url + if url is not None: + return web.Response(status=302, headers={ + 'location': url + }) + return web.Response(status=500) + data, content_type = await player.async_get_media_image() if data is None: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 1e53e607360..0875df623b3 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -39,6 +39,7 @@ MEDIA_TYPE_PLAYLIST = 'playlist' MEDIA_TYPE_IMAGE = 'image' MEDIA_TYPE_URL = 'url' MEDIA_TYPE_GAME = 'game' +MEDIA_TYPE_APP = 'app' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' SERVICE_PLAY_MEDIA = 'play_media' diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index c9da38d3657..c641eda1a49 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -185,73 +185,6 @@ snapcast_restore: description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room' -sonos_join: - description: Group player together. - fields: - master: - description: Entity ID of the player that should become the coordinator of the group. - example: 'media_player.living_room_sonos' - entity_id: - description: Name(s) of entities that will coordinate the grouping. Platform dependent. - example: 'media_player.living_room_sonos' - -sonos_unjoin: - description: Unjoin the player from a group. - fields: - entity_id: - description: Name(s) of entities that will be unjoined from their group. Platform dependent. - example: 'media_player.living_room_sonos' - -sonos_snapshot: - description: Take a snapshot of the media player. - fields: - entity_id: - description: Name(s) of entities that will be snapshot. Platform dependent. - example: 'media_player.living_room_sonos' - with_group: - description: True (default) or False. Snapshot with all group attributes. - example: 'true' - -sonos_restore: - description: Restore a snapshot of the media player. - fields: - entity_id: - description: Name(s) of entities that will be restored. Platform dependent. - example: 'media_player.living_room_sonos' - with_group: - description: True (default) or False. Restore with all group attributes. - example: 'true' - -sonos_set_sleep_timer: - description: Set a Sonos timer. - fields: - entity_id: - description: Name(s) of entities that will have a timer set. - example: 'media_player.living_room_sonos' - sleep_time: - description: Number of seconds to set the timer. - example: '900' - -sonos_clear_sleep_timer: - description: Clear a Sonos timer. - fields: - entity_id: - description: Name(s) of entities that will have the timer cleared. - example: 'media_player.living_room_sonos' - -sonos_set_option: - description: Set Sonos sound options. - fields: - entity_id: - description: Name(s) of entities that will have options set. - example: 'media_player.living_room_sonos' - night_sound: - description: Enable Night Sound mode - example: 'true' - speech_enhance: - description: Enable Speech Enhancement mode - example: 'true' - channels_seek_forward: description: Seek forward by a set number of seconds. fields: diff --git a/homeassistant/components/meteoalarm/__init__.py b/homeassistant/components/meteoalarm/__init__.py new file mode 100644 index 00000000000..f9a1fd9786f --- /dev/null +++ b/homeassistant/components/meteoalarm/__init__.py @@ -0,0 +1 @@ +"""The meteoalarm component.""" diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py new file mode 100644 index 00000000000..8af43d3b087 --- /dev/null +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -0,0 +1,99 @@ +"""Binary Sensor for MeteoAlarm.eu.""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_COUNTRY = 'country' +CONF_PROVINCE = 'province' +CONF_LANGUAGE = 'language' + +ATTRIBUTION = "Information provided by MeteoAlarm." + +DEFAULT_NAME = 'meteoalarm' +DEFAULT_DEVICE_CLASS = 'safety' + +ICON = 'mdi:alert' + +SCAN_INTERVAL = timedelta(minutes=30) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COUNTRY): cv.string, + vol.Required(CONF_PROVINCE): cv.string, + vol.Optional(CONF_LANGUAGE, default='en'): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the MeteoAlarm binary sensor platform.""" + from meteoalertapi import Meteoalert + + country = config[CONF_COUNTRY] + province = config[CONF_PROVINCE] + language = config[CONF_LANGUAGE] + name = config[CONF_NAME] + + try: + api = Meteoalert(country, province, language) + except KeyError(): + _LOGGER.error("Wrong country digits, or province name") + return + + add_entities([MeteoAlertBinarySensor(api, name)], True) + + +class MeteoAlertBinarySensor(BinarySensorDevice): + """Representation of a MeteoAlert binary sensor.""" + + def __init__(self, api, name): + """Initialize the MeteoAlert binary sensor.""" + self._name = name + self._attributes = {} + self._state = None + self._api = api + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the status of the binary sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION + return self._attributes + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return DEFAULT_DEVICE_CLASS + + def update(self): + """Update device state.""" + alert = self._api.get_alert() + if alert: + self._attributes = alert + self._state = True + else: + self._attributes = {} + self._state = False diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json new file mode 100644 index 00000000000..d84749547ae --- /dev/null +++ b/homeassistant/components/meteoalarm/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "meteoalarm", + "name": "meteoalarm", + "documentation": "https://www.home-assistant.io/components/meteoalarm", + "requirements": [ + "meteoalertapi==0.0.8" + ], + "dependencies": [], + "codeowners": ["@rolfberkenbosch"] +} diff --git a/homeassistant/components/mobile_app/.translations/cs.json b/homeassistant/components/mobile_app/.translations/cs.json new file mode 100644 index 00000000000..b240e122485 --- /dev/null +++ b/homeassistant/components/mobile_app/.translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "install_app": "Otev\u0159ete mobiln\u00ed aplikaci pro nastaven\u00ed integrace s aplikac\u00ed Home Assistant. Seznam kompatibiln\u00edch aplikac\u00ed naleznete v [dokumentaci]({apps_url})." + }, + "step": { + "confirm": { + "description": "Chcete nastavit komponentu Mobiln\u00ed aplikace?", + "title": "Mobiln\u00ed aplikace" + } + }, + "title": "Mobiln\u00ed aplikace" + } +} \ No newline at end of file diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index ee593588ef8..6aec4307464 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -81,7 +81,7 @@ def registration_context(registration: Dict) -> Context: def empty_okay_response(headers: Dict = None, status: int = 200) -> Response: """Return a Response with empty JSON object and a 200.""" - return Response(body='{}', status=status, content_type='application/json', + return Response(text='{}', status=status, content_type='application/json', headers=headers) diff --git a/homeassistant/components/moon/.translations/sensor.cs.json b/homeassistant/components/moon/.translations/sensor.cs.json index ef1d5bf5f13..d39ee3707d6 100644 --- a/homeassistant/components/moon/.translations/sensor.cs.json +++ b/homeassistant/components/moon/.translations/sensor.cs.json @@ -2,11 +2,7 @@ "state": { "first_quarter": "Prvn\u00ed \u010dtvr\u0165", "full_moon": "\u00dapln\u011bk", - "last_quarter": "Posledn\u00ed \u010dtvr\u0165", - "new_moon": "Nov", - "waning_crescent": "Couvaj\u00edc\u00ed srpek", - "waning_gibbous": "Couvaj\u00edc\u00ed m\u011bs\u00edc", "waxing_crescent": "Dor\u016fstaj\u00edc\u00ed srpek", - "waxing_gibbous": "Dor\u016fstaj\u00edc\u00ed m\u011bs\u00edc" + "waxing_gibbous": "Prvn\u00ed \u010dtvr\u0165" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index 7af8f43b897..e0c94ac621a 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -13,7 +13,7 @@ "discovery": "Habilitar descubrimiento", "password": "Contrase\u00f1a", "port": "Puerto", - "username": "Usuario" + "username": "Nombre de usuario" }, "description": "Por favor, introduce la informaci\u00f3n de tu agente MQTT", "title": "MQTT" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e226e966b09..3de53145cfc 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -69,6 +69,7 @@ CONF_AVAILABILITY_TOPIC = 'availability_topic' CONF_PAYLOAD_AVAILABLE = 'payload_available' CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_JSON_ATTRS_TOPIC = 'json_attributes_topic' +CONF_JSON_ATTRS_TEMPLATE = 'json_attributes_template' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -242,6 +243,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, }) MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) @@ -908,10 +910,18 @@ class MqttAttributes(Entity): """(Re)Subscribe to topics.""" from .subscription import async_subscribe_topics + attr_tpl = self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE) + if attr_tpl is not None: + attr_tpl.hass = self.hass + @callback def attributes_message_received(msg: Message) -> None: try: - json_dict = json.loads(msg.payload) + payload = msg.payload + if attr_tpl is not None: + payload = attr_tpl.async_render_with_possible_json_value( + payload) + json_dict = json.loads(payload) if isinstance(json_dict, dict): self._attributes = json_dict self.async_write_ha_state() @@ -919,7 +929,7 @@ class MqttAttributes(Entity): _LOGGER.warning("JSON result was not a dictionary") self._attributes = None except ValueError: - _LOGGER.warning("Erroneous JSON: %s", msg.payload) + _LOGGER.warning("Erroneous JSON: %s", payload) self._attributes = None self._attributes_sub_state = await async_subscribe_topics( diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 4c1427d7e15..c99c73018ea 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -94,6 +94,7 @@ ABBREVIATIONS = { 'cln_tpl': 'cleaning_template', 'cmd_t': 'command_topic', 'curr_temp_t': 'current_temperature_topic', + 'curr_temp_tpl': 'current_temperature_template', 'dev': 'device', 'dev_cla': 'device_class', 'dock_t': 'docked_topic', @@ -157,6 +158,7 @@ ABBREVIATIONS = { 'send_if_off': 'send_if_off', 'set_pos_tpl': 'set_position_template', 'set_pos_t': 'set_position_topic', + 'pos_t': 'position_topic', 'spd_cmd_t': 'speed_command_topic', 'spd_stat_t': 'speed_state_topic', 'spd_val_tpl': 'speed_value_template', @@ -210,6 +212,12 @@ def clear_discovery_hash(hass, discovery_hash): del hass.data[ALREADY_DISCOVERED][discovery_hash] +class MQTTConfig(dict): + """Dummy class to allow adding attributes.""" + + pass + + async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, config_entry=None) -> bool: """Initialize of MQTT Discovery.""" @@ -236,7 +244,7 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, object_id, payload) return - payload = dict(payload) + payload = MQTTConfig(payload) for key in list(payload.keys()): abbreviated_key = key @@ -264,6 +272,10 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, discovery_hash = (component, discovery_id) if payload: + # Attach MQTT topic to the payload, used for debug prints + setattr(payload, '__configuration_source__', + "MQTT (topic: '{}')".format(topic)) + if CONF_PLATFORM in payload and 'schema' not in payload: platform = payload[CONF_PLATFORM] if (component in DEPRECATED_PLATFORM_TO_SCHEMA and diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 99aa68d1975..8b116210a10 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -9,7 +9,7 @@ from homeassistant.components.fan import ( SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity) from homeassistant.const import ( CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, - CONF_STATE, STATE_OFF, STATE_ON) + CONF_STATE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +32,7 @@ CONF_OSCILLATION_COMMAND_TOPIC = 'oscillation_command_topic' CONF_OSCILLATION_VALUE_TEMPLATE = 'oscillation_value_template' CONF_PAYLOAD_OSCILLATION_ON = 'payload_oscillation_on' CONF_PAYLOAD_OSCILLATION_OFF = 'payload_oscillation_off' +CONF_PAYLOAD_OFF_SPEED = 'payload_off_speed' CONF_PAYLOAD_LOW_SPEED = 'payload_low_speed' CONF_PAYLOAD_MEDIUM_SPEED = 'payload_medium_speed' CONF_PAYLOAD_HIGH_SPEED = 'payload_high_speed' @@ -57,12 +58,13 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MEDIUM): cv.string, + vol.Optional(CONF_PAYLOAD_OFF_SPEED, default=SPEED_OFF): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_PAYLOAD_OSCILLATION_OFF, - default=DEFAULT_PAYLOAD_OFF): cv.string, + default=OSCILLATE_OFF_PAYLOAD): cv.string, vol.Optional(CONF_PAYLOAD_OSCILLATION_ON, - default=DEFAULT_PAYLOAD_ON): cv.string, + default=OSCILLATE_ON_PAYLOAD): cv.string, vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_SPEED_LIST, default=[SPEED_OFF, SPEED_LOW, @@ -172,13 +174,14 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) } self._payload = { - STATE_ON: config[CONF_PAYLOAD_ON], - STATE_OFF: config[CONF_PAYLOAD_OFF], - OSCILLATE_ON_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_ON], - OSCILLATE_OFF_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_OFF], - SPEED_LOW: config[CONF_PAYLOAD_LOW_SPEED], - SPEED_MEDIUM: config[CONF_PAYLOAD_MEDIUM_SPEED], - SPEED_HIGH: config[CONF_PAYLOAD_HIGH_SPEED], + 'STATE_ON': config[CONF_PAYLOAD_ON], + 'STATE_OFF': config[CONF_PAYLOAD_OFF], + 'OSCILLATE_ON_PAYLOAD': config[CONF_PAYLOAD_OSCILLATION_ON], + 'OSCILLATE_OFF_PAYLOAD': config[CONF_PAYLOAD_OSCILLATION_OFF], + 'SPEED_LOW': config[CONF_PAYLOAD_LOW_SPEED], + 'SPEED_MEDIUM': config[CONF_PAYLOAD_MEDIUM_SPEED], + 'SPEED_HIGH': config[CONF_PAYLOAD_HIGH_SPEED], + 'SPEED_OFF': config[CONF_PAYLOAD_OFF_SPEED], } optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -208,9 +211,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def state_received(msg): """Handle new received MQTT message.""" payload = templates[CONF_STATE](msg.payload) - if payload == self._payload[STATE_ON]: + if payload == self._payload['STATE_ON']: self._state = True - elif payload == self._payload[STATE_OFF]: + elif payload == self._payload['STATE_OFF']: self._state = False self.async_write_ha_state() @@ -224,12 +227,14 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def speed_received(msg): """Handle new received MQTT message for the speed.""" payload = templates[ATTR_SPEED](msg.payload) - if payload == self._payload[SPEED_LOW]: + if payload == self._payload['SPEED_LOW']: self._speed = SPEED_LOW - elif payload == self._payload[SPEED_MEDIUM]: + elif payload == self._payload['SPEED_MEDIUM']: self._speed = SPEED_MEDIUM - elif payload == self._payload[SPEED_HIGH]: + elif payload == self._payload['SPEED_HIGH']: self._speed = SPEED_HIGH + elif payload == self._payload['SPEED_OFF']: + self._speed = SPEED_OFF self.async_write_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: @@ -243,9 +248,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def oscillation_received(msg): """Handle new received MQTT message for the oscillation.""" payload = templates[OSCILLATION](msg.payload) - if payload == self._payload[OSCILLATE_ON_PAYLOAD]: + if payload == self._payload['OSCILLATE_ON_PAYLOAD']: self._oscillation = True - elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: + elif payload == self._payload['OSCILLATE_OFF_PAYLOAD']: self._oscillation = False self.async_write_ha_state() @@ -314,10 +319,13 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_ON], self._config[CONF_QOS], + self._payload['STATE_ON'], self._config[CONF_QOS], self._config[CONF_RETAIN]) if speed: await self.async_set_speed(speed) + if self._optimistic: + self._state = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn off the entity. @@ -326,8 +334,11 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_OFF], self._config[CONF_QOS], + self._payload['STATE_OFF'], self._config[CONF_QOS], self._config[CONF_RETAIN]) + if self._optimistic: + self._state = False + self.async_write_ha_state() async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan. @@ -338,11 +349,13 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, return if speed == SPEED_LOW: - mqtt_payload = self._payload[SPEED_LOW] + mqtt_payload = self._payload['SPEED_LOW'] elif speed == SPEED_MEDIUM: - mqtt_payload = self._payload[SPEED_MEDIUM] + mqtt_payload = self._payload['SPEED_MEDIUM'] elif speed == SPEED_HIGH: - mqtt_payload = self._payload[SPEED_HIGH] + mqtt_payload = self._payload['SPEED_HIGH'] + elif speed == SPEED_OFF: + mqtt_payload = self._payload['SPEED_OFF'] else: mqtt_payload = speed @@ -364,9 +377,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, return if oscillating is False: - payload = self._payload[OSCILLATE_OFF_PAYLOAD] + payload = self._payload['OSCILLATE_OFF_PAYLOAD'] else: - payload = self._payload[OSCILLATE_ON_PAYLOAD] + payload = self._payload['OSCILLATE_ON_PAYLOAD'] mqtt.async_publish( self.hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC], diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py new file mode 100644 index 00000000000..f69e41985d6 --- /dev/null +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -0,0 +1,97 @@ +""" +Support for MQTT vacuums. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/vacuum.mqtt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.vacuum import DOMAIN +from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +CONF_SCHEMA = 'schema' +LEGACY = 'legacy' +STATE = 'state' + + +def validate_mqtt_vacuum(value): + """Validate MQTT vacuum schema.""" + from . import schema_legacy + from . import schema_state + + schemas = { + LEGACY: schema_legacy.PLATFORM_SCHEMA_LEGACY, + STATE: schema_state.PLATFORM_SCHEMA_STATE, + } + return schemas[value[CONF_SCHEMA]](value) + + +def services_to_strings(services, service_to_string): + """Convert SUPPORT_* service bitmask to list of service strings.""" + strings = [] + for service in service_to_string: + if service & services: + strings.append(service_to_string[service]) + return strings + + +def strings_to_services(strings, string_to_service): + """Convert service strings to SUPPORT_* service bitmask.""" + services = 0 + for string in strings: + services |= string_to_service[string] + return services + + +MQTT_VACUUM_SCHEMA = vol.Schema({ + vol.Optional(CONF_SCHEMA, default=LEGACY): vol.All( + vol.Lower, vol.Any(LEGACY, STATE)) +}) + +PLATFORM_SCHEMA = vol.All(MQTT_VACUUM_SCHEMA.extend({ +}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up MQTT vacuum through configuration.yaml.""" + await _async_setup_entity(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.""" + async def async_discover(discovery_payload): + """Discover and add a MQTT vacuum.""" + try: + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, config_entry, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(DOMAIN, 'mqtt'), async_discover) + + +async def _async_setup_entity(config, async_add_entities, config_entry, + discovery_hash=None): + """Set up the MQTT vacuum.""" + from . import schema_legacy + from . import schema_state + setup_entity = { + LEGACY: schema_legacy.async_setup_entity_legacy, + STATE: schema_state.async_setup_entity_state, + } + await setup_entity[config[CONF_SCHEMA]]( + config, async_add_entities, config_entry, discovery_hash) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py similarity index 87% rename from homeassistant/components/mqtt/vacuum.py rename to homeassistant/components/mqtt/vacuum/schema_legacy.py index 5895d52e9dc..6321d98fcd7 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -1,4 +1,4 @@ -"""Support for a generic MQTT vacuum.""" +"""Support for Legacy MQTT vacuum.""" import logging import json @@ -6,20 +6,20 @@ import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.vacuum import ( - DOMAIN, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.icon import icon_for_battery_level -from . import ( - ATTR_DISCOVERY_HASH, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, +from homeassistant.components.mqtt import ( + CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) -from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash + +from . import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services _LOGGER = logging.getLogger(__name__) @@ -39,24 +39,6 @@ SERVICE_TO_STRING = { STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} - -def services_to_strings(services): - """Convert SUPPORT_* service bitmask to list of service strings.""" - strings = [] - for service in SERVICE_TO_STRING: - if service & services: - strings.append(SERVICE_TO_STRING[service]) - return strings - - -def strings_to_services(strings): - """Convert service strings to SUPPORT_* service bitmask.""" - services = 0 - for string in strings: - services |= STRING_TO_SERVICE[string] - return services - - DEFAULT_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP |\ SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\ SUPPORT_CLEAN_SPOT @@ -96,9 +78,10 @@ DEFAULT_PAYLOAD_STOP = 'stop' DEFAULT_PAYLOAD_TURN_OFF = 'turn_off' DEFAULT_PAYLOAD_TURN_ON = 'turn_on' DEFAULT_RETAIN = False -DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES) +DEFAULT_SERVICE_STRINGS = services_to_strings( + DEFAULT_SERVICES, SERVICE_TO_STRING) -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_LEGACY = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Inclusive(CONF_BATTERY_LEVEL_TEMPLATE, 'battery'): cv.template, vol.Inclusive(CONF_BATTERY_LEVEL_TOPIC, 'battery'): mqtt.valid_publish_topic, @@ -137,44 +120,19 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(mqtt.CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( - mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_VACUUM_SCHEMA.schema) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up MQTT vacuum through configuration.yaml.""" - await _async_setup_entity(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.""" - async def async_discover(discovery_payload): - """Discover and add a MQTT vacuum.""" - try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, config_entry, - discovery_hash) - except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) - raise - - async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW.format(DOMAIN, 'mqtt'), async_discover) - - -async def _async_setup_entity(config, async_add_entities, config_entry, - discovery_hash=None): - """Set up the MQTT vacuum.""" +async def async_setup_entity_legacy(config, async_add_entities, + config_entry, discovery_hash): + """Set up a MQTT Vacuum Legacy.""" async_add_entities([MqttVacuum(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, VacuumDevice): - """Representation of a MQTT-controlled vacuum.""" + """Representation of a MQTT-controlled legacy vacuum.""" def __init__(self, config, config_entry, discovery_info): """Initialize the vacuum.""" @@ -204,7 +162,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._name = config[CONF_NAME] supported_feature_strings = config[CONF_SUPPORTED_FEATURES] self._supported_features = strings_to_services( - supported_feature_strings + supported_feature_strings, STRING_TO_SERVICE ) self._fan_speed_list = config[CONF_FAN_SPEED_LIST] self._qos = config[mqtt.CONF_QOS] @@ -248,7 +206,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" - config = PLATFORM_SCHEMA(discovery_payload) + config = PLATFORM_SCHEMA_LEGACY(discovery_payload) self._setup_from_config(config) await self.attributes_discovery_update(config) await self.availability_discovery_update(config) @@ -374,7 +332,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def status(self): """Return a status string for the vacuum.""" if self.supported_features & SUPPORT_STATUS == 0: - return + return None return self._status @@ -382,7 +340,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def fan_speed(self): """Return the status of the vacuum.""" if self.supported_features & SUPPORT_FAN_SPEED == 0: - return + return None return self._fan_speed @@ -429,7 +387,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_turn_off(self, **kwargs): """Turn the vacuum off.""" if self.supported_features & SUPPORT_TURN_OFF == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_TURN_OFF], @@ -440,7 +398,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_stop(self, **kwargs): """Stop the vacuum.""" if self.supported_features & SUPPORT_STOP == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_STOP], @@ -451,7 +409,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_clean_spot(self, **kwargs): """Perform a spot clean-up.""" if self.supported_features & SUPPORT_CLEAN_SPOT == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_CLEAN_SPOT], @@ -462,7 +420,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_locate(self, **kwargs): """Locate the vacuum (usually by playing a song).""" if self.supported_features & SUPPORT_LOCATE == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_LOCATE], @@ -473,7 +431,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_start_pause(self, **kwargs): """Start, pause or resume the cleaning task.""" if self.supported_features & SUPPORT_PAUSE == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_START_PAUSE], @@ -484,7 +442,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_return_to_base(self, **kwargs): """Tell the vacuum to return to its dock.""" if self.supported_features & SUPPORT_RETURN_HOME == 0: - return + return None mqtt.async_publish(self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_RETURN_TO_BASE], @@ -494,10 +452,9 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" - if self.supported_features & SUPPORT_FAN_SPEED == 0: - return - if not self._fan_speed_list or fan_speed not in self._fan_speed_list: - return + if ((self.supported_features & SUPPORT_FAN_SPEED == 0) or + fan_speed not in self._fan_speed_list): + return None mqtt.async_publish(self.hass, self._set_fan_speed_topic, fan_speed, self._qos, self._retain) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py new file mode 100644 index 00000000000..2e0921ad19d --- /dev/null +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -0,0 +1,339 @@ +"""Support for a State MQTT vacuum.""" +import logging +import json + +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.components.vacuum import ( + SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_START, + SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, + SUPPORT_STATUS, SUPPORT_STOP, STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, + STATE_IDLE, STATE_RETURNING, STATE_ERROR, StateVacuumDevice) +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.mqtt import ( + CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription, + CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, CONF_QOS) + +from . import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services + +_LOGGER = logging.getLogger(__name__) + +SERVICE_TO_STRING = { + SUPPORT_START: 'start', + SUPPORT_PAUSE: 'pause', + SUPPORT_STOP: 'stop', + SUPPORT_RETURN_HOME: 'return_home', + SUPPORT_FAN_SPEED: 'fan_speed', + SUPPORT_BATTERY: 'battery', + SUPPORT_STATUS: 'status', + SUPPORT_SEND_COMMAND: 'send_command', + SUPPORT_LOCATE: 'locate', + SUPPORT_CLEAN_SPOT: 'clean_spot', +} + +STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} + + +DEFAULT_SERVICES = SUPPORT_START | SUPPORT_STOP |\ + SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\ + SUPPORT_CLEAN_SPOT +ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\ + SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND + +BATTERY = 'battery_level' +FAN_SPEED = 'fan_speed' +STATE = "state" + +POSSIBLE_STATES = { + STATE_IDLE: STATE_IDLE, + STATE_DOCKED: STATE_DOCKED, + STATE_ERROR: STATE_ERROR, + STATE_PAUSED: STATE_PAUSED, + STATE_RETURNING: STATE_RETURNING, + STATE_CLEANING: STATE_CLEANING, +} + +CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES +CONF_PAYLOAD_TURN_ON = 'payload_turn_on' +CONF_PAYLOAD_TURN_OFF = 'payload_turn_off' +CONF_PAYLOAD_RETURN_TO_BASE = 'payload_return_to_base' +CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_CLEAN_SPOT = 'payload_clean_spot' +CONF_PAYLOAD_LOCATE = 'payload_locate' +CONF_PAYLOAD_START = 'payload_start' +CONF_PAYLOAD_PAUSE = 'payload_pause' +CONF_STATE_TEMPLATE = 'state_template' +CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic' +CONF_FAN_SPEED_LIST = 'fan_speed_list' +CONF_SEND_COMMAND_TOPIC = 'send_command_topic' + +DEFAULT_NAME = 'MQTT State Vacuum' +DEFAULT_RETAIN = False +DEFAULT_SERVICE_STRINGS = services_to_strings( + DEFAULT_SERVICES, SERVICE_TO_STRING) +DEFAULT_PAYLOAD_RETURN_TO_BASE = 'return_to_base' +DEFAULT_PAYLOAD_STOP = 'stop' +DEFAULT_PAYLOAD_CLEAN_SPOT = 'clean_spot' +DEFAULT_PAYLOAD_LOCATE = 'locate' +DEFAULT_PAYLOAD_START = 'start' +DEFAULT_PAYLOAD_PAUSE = 'pause' + +PLATFORM_SCHEMA_STATE = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_CLEAN_SPOT, + default=DEFAULT_PAYLOAD_CLEAN_SPOT): cv.string, + vol.Optional(CONF_PAYLOAD_LOCATE, + default=DEFAULT_PAYLOAD_LOCATE): cv.string, + vol.Optional(CONF_PAYLOAD_RETURN_TO_BASE, + default=DEFAULT_PAYLOAD_RETURN_TO_BASE): cv.string, + vol.Optional(CONF_PAYLOAD_START, + default=DEFAULT_PAYLOAD_START): cv.string, + vol.Optional(CONF_PAYLOAD_PAUSE, + default=DEFAULT_PAYLOAD_PAUSE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS): + vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_VACUUM_SCHEMA.schema) + + +async def async_setup_entity_state(config, async_add_entities, + config_entry, discovery_hash): + """Set up a State MQTT Vacuum.""" + async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)]) + + +# pylint: disable=too-many-ancestors +class MqttStateVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, StateVacuumDevice): + """Representation of a MQTT-controlled state vacuum.""" + + def __init__(self, config, config_entry, discovery_info): + """Initialize the vacuum.""" + self._state = None + self._state_attrs = {} + self._fan_speed_list = [] + self._sub_state = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + device_config = config.get(CONF_DEVICE) + + MqttAttributes.__init__(self, config) + MqttAvailability.__init__(self, config) + MqttDiscoveryUpdate.__init__(self, discovery_info, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) + + def _setup_from_config(self, config): + self._config = config + self._name = config[CONF_NAME] + supported_feature_strings = config[CONF_SUPPORTED_FEATURES] + self._supported_features = strings_to_services( + supported_feature_strings, STRING_TO_SERVICE + ) + self._fan_speed_list = config[CONF_FAN_SPEED_LIST] + self._command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) + self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) + self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) + + self._payloads = { + key: config.get(key) for key in ( + CONF_PAYLOAD_START, + CONF_PAYLOAD_PAUSE, + CONF_PAYLOAD_STOP, + CONF_PAYLOAD_RETURN_TO_BASE, + CONF_PAYLOAD_CLEAN_SPOT, + CONF_PAYLOAD_LOCATE + ) + } + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_STATE(discovery_payload) + self._setup_from_config(config) + await self.attributes_discovery_update(config) + await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) + await self._subscribe_topics() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) + await MqttAvailability.async_will_remove_from_hass(self) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass + topics = {} + + @callback + def state_message_received(msg): + """Handle state MQTT message.""" + payload = msg.payload + if template is not None: + payload = template.async_render_with_possible_json_value( + payload) + else: + payload = json.loads(payload) + if STATE in payload and payload[STATE] in POSSIBLE_STATES: + self._state = POSSIBLE_STATES[payload[STATE]] + del payload[STATE] + self._state_attrs.update(payload) + self.async_write_ha_state() + + if self._config.get(CONF_STATE_TOPIC): + topics['state_position_topic'] = { + 'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': state_message_received, + 'qos': self._config[CONF_QOS]} + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, topics) + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def state(self): + """Return state of vacuum.""" + return self._state + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def fan_speed(self): + """Return fan speed of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return None + + return self._state_attrs.get(FAN_SPEED, 0) + + @property + def fan_speed_list(self): + """Return fan speed list of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return None + return self._fan_speed_list + + @property + def battery_level(self): + """Return battery level of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return None + return max(0, min(100, self._state_attrs.get(BATTERY, 0))) + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + async def async_start(self): + """Start the vacuum.""" + if self.supported_features & SUPPORT_START == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_START], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_pause(self): + """Pause the vacuum.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_PAUSE], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_stop(self, **kwargs): + """Stop the vacuum.""" + if self.supported_features & SUPPORT_STOP == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_STOP], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if ((self.supported_features & SUPPORT_FAN_SPEED == 0) or + (fan_speed not in self._fan_speed_list)): + return None + mqtt.async_publish(self.hass, self._set_fan_speed_topic, + fan_speed, + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_return_to_base(self, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_RETURN_TO_BASE], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_CLEAN_SPOT], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_locate(self, **kwargs): + """Locate the vacuum (usually by playing a song).""" + if self.supported_features & SUPPORT_LOCATE == 0: + return None + mqtt.async_publish(self.hass, self._command_topic, + self._config[CONF_PAYLOAD_LOCATE], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) + + async def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + if self.supported_features & SUPPORT_SEND_COMMAND == 0: + return None + if params: + message = {"command": command} + message.update(params) + message = json.dumps(message) + else: + message = command + mqtt.async_publish(self.hass, self._send_command_topic, + message, + self._config[CONF_QOS], + self._config[CONF_RETAIN]) diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index fbe3621a4af..f9272b4ab59 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -1,5 +1,5 @@ """MySensors constants.""" -import homeassistant.helpers.config_validation as cv +from collections import defaultdict ATTR_DEVICES = 'devices' @@ -25,117 +25,102 @@ NODE_CALLBACK = 'mysensors_node_callback_{}_{}' TYPE = 'type' UPDATE_DELAY = 0.1 -# MySensors const schemas -BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} -CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} -LIGHT_DIMMER_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_DIMMER', - SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} -LIGHT_PERCENTAGE_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_PERCENTAGE', - SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGB_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { - 'V_RGB': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGBW_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { - 'V_RGBW': cv.string, 'V_STATUS': cv.string}} -NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} -DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} -DUST_SCHEMA = [ - {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] -SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} -SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} -MYSENSORS_CONST_SCHEMA = { - 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SPRINKLER': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_WATER_LEAK': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SOUND': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_VIBRATION': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOISTURE': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_HVAC': [CLIMATE_SCHEMA], - 'S_COVER': [ - {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, - {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, - {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, - {PLATFORM: 'cover', TYPE: 'V_STATUS'}], - 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], - 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], - 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], - 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], - 'S_GPS': [ - DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], - 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], - 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], - 'S_BARO': [ - {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, - {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], - 'S_WIND': [ - {PLATFORM: 'sensor', TYPE: 'V_WIND'}, - {PLATFORM: 'sensor', TYPE: 'V_GUST'}, - {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], - 'S_RAIN': [ - {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, - {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], - 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], - 'S_WEIGHT': [ - {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_POWER': [ - {PLATFORM: 'sensor', TYPE: 'V_WATT'}, - {PLATFORM: 'sensor', TYPE: 'V_KWH'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR'}, - {PLATFORM: 'sensor', TYPE: 'V_VA'}, - {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], - 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], - 'S_LIGHT_LEVEL': [ - {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], - 'S_IR': [ - {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, - {PLATFORM: 'switch', TYPE: 'V_IR_SEND', - SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], - 'S_WATER': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_CUSTOM': [ - {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, - {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], - 'S_SCENE_CONTROLLER': [ - {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, - {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], - 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], - 'S_MULTIMETER': [ - {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, - {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_GAS': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_WATER_QUALITY': [ - {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, - {PLATFORM: 'sensor', TYPE: 'V_PH'}, - {PLATFORM: 'sensor', TYPE: 'V_ORP'}, - {PLATFORM: 'sensor', TYPE: 'V_EC'}, - {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_AIR_QUALITY': DUST_SCHEMA, - 'S_DUST': DUST_SCHEMA, - 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], - 'S_BINARY': [SWITCH_STATUS_SCHEMA], - 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +BINARY_SENSOR_TYPES = { + 'S_DOOR': 'V_TRIPPED', + 'S_MOTION': 'V_TRIPPED', + 'S_SMOKE': 'V_TRIPPED', + 'S_SPRINKLER': 'V_TRIPPED', + 'S_WATER_LEAK': 'V_TRIPPED', + 'S_SOUND': 'V_TRIPPED', + 'S_VIBRATION': 'V_TRIPPED', + 'S_MOISTURE': 'V_TRIPPED', } + +CLIMATE_TYPES = { + 'S_HVAC': 'V_HVAC_FLOW_STATE', +} + +COVER_TYPES = { + 'S_COVER': ['V_DIMMER', 'V_PERCENTAGE', 'V_LIGHT', 'V_STATUS'], +} + +DEVICE_TRACKER_TYPES = { + 'S_GPS': 'V_POSITION', +} + +LIGHT_TYPES = { + 'S_DIMMER': ['V_DIMMER', 'V_PERCENTAGE'], + 'S_RGB_LIGHT': 'V_RGB', + 'S_RGBW_LIGHT': 'V_RGBW', +} + +NOTIFY_TYPES = { + 'S_INFO': 'V_TEXT', +} + +SENSOR_TYPES = { + 'S_SOUND': 'V_LEVEL', + 'S_VIBRATION': 'V_LEVEL', + 'S_MOISTURE': 'V_LEVEL', + 'S_INFO': 'V_TEXT', + 'S_GPS': 'V_POSITION', + 'S_TEMP': 'V_TEMP', + 'S_HUM': 'V_HUM', + 'S_BARO': ['V_PRESSURE', 'V_FORECAST'], + 'S_WIND': ['V_WIND', 'V_GUST', 'V_DIRECTION'], + 'S_RAIN': ['V_RAIN', 'V_RAINRATE'], + 'S_UV': 'V_UV', + 'S_WEIGHT': ['V_WEIGHT', 'V_IMPEDANCE'], + 'S_POWER': ['V_WATT', 'V_KWH', 'V_VAR', 'V_VA', 'V_POWER_FACTOR'], + 'S_DISTANCE': 'V_DISTANCE', + 'S_LIGHT_LEVEL': ['V_LIGHT_LEVEL', 'V_LEVEL'], + 'S_IR': 'V_IR_RECEIVE', + 'S_WATER': ['V_FLOW', 'V_VOLUME'], + 'S_CUSTOM': ['V_VAR1', 'V_VAR2', 'V_VAR3', 'V_VAR4', 'V_VAR5', 'V_CUSTOM'], + 'S_SCENE_CONTROLLER': ['V_SCENE_ON', 'V_SCENE_OFF'], + 'S_COLOR_SENSOR': 'V_RGB', + 'S_MULTIMETER': ['V_VOLTAGE', 'V_CURRENT', 'V_IMPEDANCE'], + 'S_GAS': ['V_FLOW', 'V_VOLUME'], + 'S_WATER_QUALITY': ['V_TEMP', 'V_PH', 'V_ORP', 'V_EC'], + 'S_AIR_QUALITY': ['V_DUST_LEVEL', 'V_LEVEL'], + 'S_DUST': ['V_DUST_LEVEL', 'V_LEVEL'], +} + +SWITCH_TYPES = { + 'S_LIGHT': 'V_LIGHT', + 'S_BINARY': 'V_STATUS', + 'S_DOOR': 'V_ARMED', + 'S_MOTION': 'V_ARMED', + 'S_SMOKE': 'V_ARMED', + 'S_SPRINKLER': 'V_STATUS', + 'S_WATER_LEAK': 'V_ARMED', + 'S_SOUND': 'V_ARMED', + 'S_VIBRATION': 'V_ARMED', + 'S_MOISTURE': 'V_ARMED', + 'S_IR': 'V_IR_SEND', + 'S_LOCK': 'V_LOCK_STATUS', + 'S_WATER_QUALITY': 'V_STATUS', +} + + +PLATFORM_TYPES = { + 'binary_sensor': BINARY_SENSOR_TYPES, + 'climate': CLIMATE_TYPES, + 'cover': COVER_TYPES, + 'device_tracker': DEVICE_TRACKER_TYPES, + 'light': LIGHT_TYPES, + 'notify': NOTIFY_TYPES, + 'sensor': SENSOR_TYPES, + 'switch': SWITCH_TYPES, +} + +FLAT_PLATFORM_TYPES = { + (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) +for platform, platform_types in PLATFORM_TYPES.items(): + for s_type_name in platform_types: + TYPE_TO_PLATFORMS[s_type_name].append(platform) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 62ea20cbb91..19f8b82a669 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -20,7 +20,7 @@ from .const import ( CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, MYSENSORS_GATEWAY_READY, MYSENSORS_GATEWAYS) from .handler import HANDLERS -from .helpers import discover_mysensors_platform, validate_child +from .helpers import discover_mysensors_platform, validate_child, validate_node _LOGGER = logging.getLogger(__name__) @@ -161,6 +161,8 @@ async def _discover_persistent_devices(hass, hass_config, gateway): 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) diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 886660baffe..cf936b84905 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -7,26 +7,17 @@ from homeassistant.util import decorator from .const import MYSENSORS_GATEWAY_READY, CHILD_CALLBACK, NODE_CALLBACK from .device import get_mysensors_devices -from .helpers import discover_mysensors_platform, validate_child +from .helpers import discover_mysensors_platform, validate_set_msg _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -@HANDLERS.register('presentation') -async def handle_presentation(hass, hass_config, msg): - """Handle a mysensors presentation message.""" - # Handle both node and child presentation. - from mysensors.const import SYSTEM_CHILD_ID - if msg.child_id == SYSTEM_CHILD_ID: - return - _handle_child_update(hass, hass_config, msg) - - @HANDLERS.register('set') async def handle_set(hass, hass_config, msg): """Handle a mysensors set message.""" - _handle_child_update(hass, hass_config, msg) + validated = validate_set_msg(msg) + _handle_child_update(hass, hass_config, validated) @HANDLERS.register('internal') @@ -77,14 +68,12 @@ async def handle_gateway_ready(hass, hass_config, msg): @callback -def _handle_child_update(hass, hass_config, msg): +def _handle_child_update(hass, hass_config, validated): """Handle a child update.""" - child = msg.gateway.sensors[msg.node_id].children[msg.child_id] signals = [] # Update all platforms for the device via dispatcher. - # Add/update entity if schema validates to true. - validated = validate_child(msg.gateway, msg.node_id, child) + # Add/update entity for validated children. for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) new_dev_ids = [] diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index a49967cf835..24e1cbc91c9 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -8,11 +8,12 @@ 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.util.decorator import Registry -from .const import ( - ATTR_DEVICES, DOMAIN, MYSENSORS_CONST_SCHEMA, PLATFORM, SCHEMA, TYPE) +from .const import ATTR_DEVICES, DOMAIN, FLAT_PLATFORM_TYPES, TYPE_TO_PLATFORMS _LOGGER = logging.getLogger(__name__) +SCHEMAS = Registry() @callback @@ -24,58 +25,116 @@ def discover_mysensors_platform(hass, hass_config, platform, new_devices): return task -def validate_child(gateway, node_id, child): - """Validate that a child has the correct values according to schema. +def default_schema(gateway, child, value_type_name): + """Return a default validation schema for value types.""" + schema = {value_type_name: cv.string} + return get_child_schema(gateway, child, value_type_name, schema) - Return a dict of platform with a list of device ids for validated devices. - """ - validated = defaultdict(list) - if not child.values: - _LOGGER.debug( - "No child values for node %s child %s", node_id, child.id) - return validated - if gateway.sensors[node_id].sketch_name is None: - _LOGGER.debug("Node %s is missing sketch name", node_id) - return validated +@SCHEMAS.register(('light', 'V_DIMMER')) +def light_dimmer_schema(gateway, child, value_type_name): + """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): + """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): + """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): + """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): + """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): + """Return a child schema.""" + set_req = gateway.const.SetReq + child_schema = child.get_schema(gateway.protocol_version) + schema = child_schema.extend( + {vol.Required( + set_req[name].value, msg=invalid_msg(gateway, child, name)): + child_schema.schema.get(set_req[name].value, valid) + for name, valid in schema.items()}, + extra=vol.ALLOW_EXTRA) + return schema + + +def invalid_msg(gateway, child, value_type_name): + """Return a message for an invalid child during schema validation.""" pres = gateway.const.Presentation set_req = gateway.const.SetReq - s_name = next( + return "{} requires value_type {}".format( + pres(child.type).name, set_req[value_type_name].name) + + +def validate_set_msg(msg): + """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) + + +def validate_node(gateway, node_id): + """Validate a node.""" + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return False + 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( (member.name for member in pres if member.value == child.type), None) - if s_name not in MYSENSORS_CONST_SCHEMA: - _LOGGER.warning("Child type %s is not supported", s_name) + value_types = [value_type] if value_type else [*child.values] + value_type_names = [ + member.name for member in set_req if member.value in value_types] + platforms = TYPE_TO_PLATFORMS.get(child_type_name, []) + if not platforms: + _LOGGER.warning("Child type %s is not supported", child.type) return validated - child_schemas = MYSENSORS_CONST_SCHEMA[s_name] - def msg(name): - """Return a message for an invalid schema.""" - return "{} requires value_type {}".format( - pres(child.type).name, set_req[name].name) + for platform in platforms: + v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] + if not isinstance(v_names, list): + v_names = [v_names] + v_names = [v_name for v_name in v_names if v_name in value_type_names] + + for v_name in v_names: + child_schema_gen = SCHEMAS.get((platform, v_name), default_schema) + child_schema = child_schema_gen(gateway, child, v_name) + try: + child_schema(child.values) + except vol.Invalid as exc: + _LOGGER.warning( + "Invalid %s on node %s, %s platform: %s", + child, node_id, platform, exc) + continue + dev_id = id(gateway), node_id, child.id, set_req[v_name].value + validated[platform].append(dev_id) - for schema in child_schemas: - platform = schema[PLATFORM] - v_name = schema[TYPE] - value_type = next( - (member.value for member in set_req if member.name == v_name), - None) - if value_type is None: - continue - _child_schema = child.get_schema(gateway.protocol_version) - vol_schema = _child_schema.extend( - {vol.Required(set_req[key].value, msg=msg(key)): - _child_schema.schema.get(set_req[key].value, val) - for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, - extra=vol.ALLOW_EXTRA) - try: - vol_schema(child.values) - except vol.Invalid as exc: - level = (logging.WARNING if value_type in child.values - else logging.DEBUG) - _LOGGER.log( - level, - "Invalid values: %s: %s platform: node %s child %s: %s", - child.values, platform, node_id, child.id, exc) - continue - dev_id = id(gateway), node_id, child.id, value_type - validated[platform].append(dev_id) return validated diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 9acd47b6238..d9154847ca0 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -8,21 +8,28 @@ SENSORS = { 'V_TEMP': [None, 'mdi:thermometer'], 'V_HUM': ['%', 'mdi:water-percent'], 'V_DIMMER': ['%', 'mdi:percent'], - 'V_LIGHT_LEVEL': ['%', 'white-balance-sunny'], + 'V_PERCENTAGE': ['%', 'mdi:percent'], + 'V_PRESSURE': [None, 'mdi:gauge'], + 'V_FORECAST': [None, 'mdi:weather-partlycloudy'], + 'V_RAIN': [None, 'mdi:weather-rainy'], + 'V_RAINRATE': [None, 'mdi:weather-rainy'], + 'V_WIND': [None, 'mdi:weather-windy'], + 'V_GUST': [None, 'mdi:weather-windy'], 'V_DIRECTION': ['°', 'mdi:compass'], 'V_WEIGHT': ['kg', 'mdi:weight-kilogram'], 'V_DISTANCE': ['m', 'mdi:ruler'], 'V_IMPEDANCE': ['ohm', None], 'V_WATT': [POWER_WATT, None], 'V_KWH': [ENERGY_KILO_WATT_HOUR, None], - 'V_FLOW': ['m', None], + 'V_LIGHT_LEVEL': ['%', 'white-balance-sunny'], + 'V_FLOW': ['m', 'mdi:gauge'], 'V_VOLUME': ['m³', None], - 'V_VOLTAGE': ['V', 'mdi:flash'], - 'V_CURRENT': ['A', 'mdi:flash-auto'], - 'V_PERCENTAGE': ['%', 'mdi:percent'], 'V_LEVEL': { 'S_SOUND': ['dB', 'mdi:volume-high'], 'S_VIBRATION': ['Hz', None], 'S_LIGHT_LEVEL': ['lx', 'white-balance-sunny']}, + 'V_VOLTAGE': ['V', 'mdi:flash'], + 'V_CURRENT': ['A', 'mdi:flash-auto'], + 'V_PH': ['pH', None], 'V_ORP': ['mV', None], 'V_EC': ['μS/cm', None], 'V_VAR': ['var', None], @@ -65,8 +72,8 @@ class MySensorsSensor(mysensors.device.MySensorsEntity): def unit_of_measurement(self): """Return the unit of measurement of this entity.""" set_req = self.gateway.const.SetReq - if (float(self.gateway.protocol_version) >= 1.5 and - set_req.V_UNIT_PREFIX in self._values): + if (float(self.gateway.protocol_version) >= 1.5 + and set_req.V_UNIT_PREFIX in self._values): return self._values[set_req.V_UNIT_PREFIX] unit, _ = self._get_sensor_type() return unit diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json index c5c46b92166..e75e2caa37a 100644 --- a/homeassistant/components/namecheapdns/manifest.json +++ b/homeassistant/components/namecheapdns/manifest.json @@ -3,7 +3,7 @@ "name": "Namecheapdns", "documentation": "https://www.home-assistant.io/components/namecheapdns", "requirements": [ - "defusedxml==0.5.0" + "defusedxml==0.6.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/nest/.translations/es.json b/homeassistant/components/nest/.translations/es.json index 25af12a3bb8..8a154101b65 100644 --- a/homeassistant/components/nest/.translations/es.json +++ b/homeassistant/components/nest/.translations/es.json @@ -4,7 +4,7 @@ "already_setup": "S\u00f3lo puedes configurar una \u00fanica cuenta de Nest.", "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", - "no_flows": "Debe configurar Nest antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/nest/)." + "no_flows": "Debes configurar Nest antes de poder autenticarte con \u00e9l. [Lee las instrucciones](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Error interno validando el c\u00f3digo", @@ -24,7 +24,7 @@ "data": { "code": "C\u00f3digo PIN" }, - "description": "Para vincular su cuenta de Nest, [autorice su cuenta] ( {url} ). \n\n Despu\u00e9s de la autorizaci\u00f3n, copie y pegue el c\u00f3digo pin provisto a continuaci\u00f3n.", + "description": "Para vincular tu cuenta de Nest, [autoriza tu cuenta]({url}).\n\nDespu\u00e9s de la autorizaci\u00f3n, copia y pega el c\u00f3digo pin a continuaci\u00f3n.", "title": "Vincular cuenta de Nest" } }, diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 9ed9051ed50..8e556e4b6c9 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -58,8 +58,8 @@ ATTR_FACE_URL = 'face_url' ATTR_SNAPSHOT_URL = 'snapshot_url' ATTR_VIGNETTE_URL = 'vignette_url' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -MIN_TIME_BETWEEN_EVENT_UPDATES = timedelta(seconds=10) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +MIN_TIME_BETWEEN_EVENT_UPDATES = timedelta(seconds=5) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index c7e91d645fc..a5e4e8aa7a7 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/components/netatmo", "requirements": [ - "pyatmo==1.10" + "pyatmo==1.11" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 161177c9c76..7b71eaf659c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,4 +1,5 @@ -"""Support for the NetAtmo Weather Service.""" +"""Support for the Netatmo Weather Service.""" +from datetime import timedelta import logging from time import time import threading @@ -7,10 +8,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( + CONF_NAME, CONF_MODE, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_BATTERY) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle from .const import DATA_NETATMO_AUTH @@ -18,17 +21,35 @@ _LOGGER = logging.getLogger(__name__) CONF_MODULES = 'modules' CONF_STATION = 'station' +CONF_AREAS = 'areas' +CONF_LAT_NE = 'lat_ne' +CONF_LON_NE = 'lon_ne' +CONF_LAT_SW = 'lat_sw' +CONF_LON_SW = 'lon_sw' -# This is the NetAtmo data upload interval in seconds +DEFAULT_MODE = 'avg' +MODE_TYPES = {'max', 'avg'} + +DEFAULT_NAME_PUBLIC = 'Netatmo Public Data' + +# This is the Netatmo data upload interval in seconds NETATMO_UPDATE_INTERVAL = 600 +# NetAtmo Public Data is uploaded to server every 10 minutes +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) + +SUPPORTED_PUBLIC_SENSOR_TYPES = [ + 'temperature', 'pressure', 'humidity', 'rain', 'windstrength', + 'guststrength' +] + SENSOR_TYPES = { - 'temperature': ['Temperature', TEMP_CELSIUS, None, + 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer', DEVICE_CLASS_TEMPERATURE], 'co2': ['CO2', 'ppm', 'mdi:cloud', None], 'pressure': ['Pressure', 'mbar', 'mdi:gauge', None], 'noise': ['Noise', 'dB', 'mdi:volume-high', None], - 'humidity': ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'humidity': ['Humidity', '%', 'mdi:water-percent', DEVICE_CLASS_HUMIDITY], 'rain': ['Rain', 'mm', 'mdi:weather-rainy', None], 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy', None], 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy', None], @@ -39,7 +60,7 @@ SENSOR_TYPES = { 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], 'windangle': ['Angle', '', 'mdi:compass', None], 'windangle_value': ['Angle Value', 'º', 'mdi:compass', None], - 'windstrength': ['Strength', 'km/h', 'mdi:weather-windy', None], + 'windstrength': ['Wind Strength', 'km/h', 'mdi:weather-windy', None], 'gustangle': ['Gust Angle', '', 'mdi:compass', None], 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass', None], 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy', None], @@ -57,6 +78,18 @@ MODULE_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATION): cv.string, vol.Optional(CONF_MODULES): MODULE_SCHEMA, + vol.Optional(CONF_AREAS): vol.All(cv.ensure_list, [ + { + vol.Required(CONF_LAT_NE): cv.latitude, + vol.Required(CONF_LAT_SW): cv.latitude, + vol.Required(CONF_LON_NE): cv.longitude, + vol.Required(CONF_LON_SW): cv.longitude, + vol.Required(CONF_MONITORED_CONDITIONS): [vol.In( + SUPPORTED_PUBLIC_SENSOR_TYPES)], + vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In(MODE_TYPES), + vol.Optional(CONF_NAME, default=DEFAULT_NAME_PUBLIC): cv.string + } + ]), }) MODULE_TYPE_OUTDOOR = 'NAModule1' @@ -68,31 +101,41 @@ MODULE_TYPE_INDOOR = 'NAModule4' def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" dev = [] + not_handled = {} auth = hass.data[DATA_NETATMO_AUTH] - if CONF_MODULES in config: - manual_config(auth, config, dev) + if config.get(CONF_AREAS) is not None: + for area in config[CONF_AREAS]: + data = NetatmoPublicData( + auth, + lat_ne=area[CONF_LAT_NE], + lon_ne=area[CONF_LON_NE], + lat_sw=area[CONF_LAT_SW], + lon_sw=area[CONF_LON_SW] + ) + for sensor_type in area[CONF_MONITORED_CONDITIONS]: + dev.append(NetatmoPublicSensor( + area[CONF_NAME], + data, + sensor_type, + area[CONF_MODE] + )) else: - auto_config(auth, config, dev) + for data_class in all_product_classes(): + data = NetatmoData(auth, data_class, config.get(CONF_STATION)) + module_items = [] + # Test if manually configured + if CONF_MODULES in config: + module_items = config[CONF_MODULES].items() + else: + # otherwise add all modules and conditions + for module_name in data.get_module_names(): + monitored_conditions = \ + data.station_data.monitoredConditions(module_name) + module_items.append( + (module_name, monitored_conditions)) - if dev: - add_entities(dev, True) - - -def manual_config(auth, config, dev): - """Handle manual configuration.""" - import pyatmo - - all_classes = all_product_classes() - not_handled = {} - - for data_class in all_classes: - data = NetAtmoData(auth, data_class, - config.get(CONF_STATION)) - try: - # Iterate each module - for module_name, monitored_conditions in \ - config[CONF_MODULES].items(): + for module_name, monitored_conditions in module_items: # Test if module exists if module_name not in data.get_module_names(): not_handled[module_name] = \ @@ -100,33 +143,15 @@ def manual_config(auth, config, dev): if module_name in not_handled else 1 else: # Only create sensors for monitored properties - for variable in monitored_conditions: - dev.append(NetAtmoSensor(data, module_name, variable)) - except pyatmo.NoDevice: - continue + for condition in monitored_conditions: + dev.append(NetatmoSensor( + data, module_name, condition.lower())) - for module_name, count in not_handled.items(): - if count == len(all_classes): + for module_name, _ in not_handled.items(): _LOGGER.error('Module name: "%s" not found', module_name) - -def auto_config(auth, config, dev): - """Handle auto configuration.""" - import pyatmo - - for data_class in all_product_classes(): - data = NetAtmoData(auth, data_class, config.get(CONF_STATION)) - try: - for module_name in data.get_module_names(): - for variable in \ - data.station_data.monitoredConditions(module_name): - if variable in SENSOR_TYPES.keys(): - dev.append(NetAtmoSensor(data, module_name, variable)) - else: - _LOGGER.warning("Ignoring unknown var %s for mod %s", - variable, module_name) - except pyatmo.NoDevice: - continue + if dev: + add_entities(dev, True) def all_product_classes(): @@ -136,7 +161,7 @@ def all_product_classes(): return [pyatmo.WeatherStationData, pyatmo.HomeCoachData] -class NetAtmoSensor(Entity): +class NetatmoSensor(Entity): """Implementation of a Netatmo sensor.""" def __init__(self, netatmo_data, module_name, sensor_type): @@ -187,7 +212,7 @@ class NetAtmoSensor(Entity): return self._unique_id def update(self): - """Get the latest data from NetAtmo API and updates the states.""" + """Get the latest data from Netatmo API and updates the states.""" self.netatmo_data.update() if self.netatmo_data.data is None: if self._state is None: @@ -362,14 +387,121 @@ class NetAtmoSensor(Entity): return -class NetAtmoData: - """Get the latest data from NetAtmo.""" +class NetatmoPublicSensor(Entity): + """Represent a single sensor in a Netatmo.""" + + def __init__(self, area_name, data, sensor_type, mode): + """Initialize the sensor.""" + self.netatmo_data = data + self.type = sensor_type + self._mode = mode + self._name = '{} {}'.format(area_name, + SENSOR_TYPES[self.type][0]) + self._area_name = area_name + self._state = None + self._device_class = SENSOR_TYPES[self.type][3] + self._icon = SENSOR_TYPES[self.type][2] + self._unit_of_measurement = SENSOR_TYPES[self.type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from Netatmo API and updates the states.""" + self.netatmo_data.update() + + if self.netatmo_data.data is None: + _LOGGER.warning("No data found for %s", self._name) + self._state = None + return + + data = None + + if self.type == 'temperature': + data = self.netatmo_data.data.getLatestTemperatures() + elif self.type == 'pressure': + data = self.netatmo_data.data.getLatestPressures() + elif self.type == 'humidity': + data = self.netatmo_data.data.getLatestHumidities() + elif self.type == 'rain': + data = self.netatmo_data.data.getLatestRain() + elif self.type == 'windstrength': + data = self.netatmo_data.data.getLatestWindStrengths() + elif self.type == 'guststrength': + data = self.netatmo_data.data.getLatestGustStrengths() + + if not data: + _LOGGER.warning("No station provides %s data in the area %s", + self.type, self._area_name) + self._state = None + return + + if self._mode == 'avg': + self._state = round(sum(data.values()) / len(data), 1) + elif self._mode == 'max': + self._state = max(data.values()) + + +class NetatmoPublicData: + """Get the latest data from Netatmo.""" + + def __init__(self, auth, lat_ne, lon_ne, lat_sw, lon_sw): + """Initialize the data object.""" + self.auth = auth + self.data = None + self.lat_ne = lat_ne + self.lon_ne = lon_ne + self.lat_sw = lat_sw + self.lon_sw = lon_sw + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Request an update from the Netatmo API.""" + import pyatmo + data = pyatmo.PublicData(self.auth, + LAT_NE=self.lat_ne, + LON_NE=self.lon_ne, + LAT_SW=self.lat_sw, + LON_SW=self.lon_sw, + filtering=True) + + if data.CountStationInArea() == 0: + _LOGGER.warning('No Stations available in this area.') + return + + self.data = data + + +class NetatmoData: + """Get the latest data from Netatmo.""" def __init__(self, auth, data_class, station): """Initialize the data object.""" self.auth = auth self.data_class = data_class - self.data = None + self.data = {} self.station_data = None self.station = station self._next_update = time() @@ -378,8 +510,6 @@ class NetAtmoData: def get_module_names(self): """Return all module available on the API as a list.""" self.update() - if not self.data: - return [] return self.data.keys() def _detect_platform_type(self): @@ -387,12 +517,16 @@ class NetAtmoData: The return can be a WeatherStationData or a HomeCoachData. """ + from pyatmo import NoDevice try: station_data = self.data_class(self.auth) _LOGGER.debug("%s detected!", str(self.data_class.__name__)) return station_data - except TypeError: - return + except NoDevice: + _LOGGER.error("No Weather or HomeCoach devices found for %s", str( + self.station + )) + raise def update(self): """Call the Netatmo API to update the data. @@ -405,11 +539,13 @@ class NetAtmoData: not self._update_in_progress.acquire(False): return + from pyatmo import NoDevice try: self.station_data = self._detect_platform_type() - if not self.station_data: - raise Exception("No Weather nor HomeCoach devices found") + except NoDevice: + return + try: if self.station is not None: self.data = self.station_data.lastData( station=self.station, exclude=3600) @@ -432,11 +568,11 @@ class NetAtmoData: newinterval = NETATMO_UPDATE_INTERVAL else: if newinterval < NETATMO_UPDATE_INTERVAL / 2: - # Never hammer the NetAtmo API more than + # Never hammer the Netatmo API more than # twice per update interval newinterval = NETATMO_UPDATE_INTERVAL / 2 _LOGGER.info( - "NetAtmo refresh interval reset to %d seconds", + "Netatmo refresh interval reset to %d seconds", newinterval) else: # Last update time not found, fall back to default value diff --git a/homeassistant/components/netatmo_public/__init__.py b/homeassistant/components/netatmo_public/__init__.py deleted file mode 100644 index c332d208ddb..00000000000 --- a/homeassistant/components/netatmo_public/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The netatmo_public component.""" diff --git a/homeassistant/components/netatmo_public/manifest.json b/homeassistant/components/netatmo_public/manifest.json deleted file mode 100644 index 1070f27b33c..00000000000 --- a/homeassistant/components/netatmo_public/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "netatmo_public", - "name": "Netatmo public", - "documentation": "https://www.home-assistant.io/components/netatmo_public", - "requirements": [], - "dependencies": [ - "netatmo" - ], - "codeowners": [] -} diff --git a/homeassistant/components/netatmo_public/sensor.py b/homeassistant/components/netatmo_public/sensor.py deleted file mode 100644 index 8295c0c0688..00000000000 --- a/homeassistant/components/netatmo_public/sensor.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Support for Sensors using public Netatmo data.""" -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, CONF_MODE, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -from homeassistant.components.netatmo.const import DATA_NETATMO_AUTH - -_LOGGER = logging.getLogger(__name__) - -CONF_AREAS = 'areas' -CONF_LAT_NE = 'lat_ne' -CONF_LON_NE = 'lon_ne' -CONF_LAT_SW = 'lat_sw' -CONF_LON_SW = 'lon_sw' - -DEFAULT_NAME = 'Netatmo Public Data' -DEFAULT_MODE = 'avg' -MODE_TYPES = {'max', 'avg'} - -SENSOR_TYPES = { - 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer', - DEVICE_CLASS_TEMPERATURE], - 'pressure': ['Pressure', 'mbar', 'mdi:gauge', None], - 'humidity': ['Humidity', '%', 'mdi:water-percent', DEVICE_CLASS_HUMIDITY], - 'rain': ['Rain', 'mm', 'mdi:weather-rainy', None], - 'windstrength': ['Wind Strength', 'km/h', 'mdi:weather-windy', None], - 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy', None], -} - -# NetAtmo Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_AREAS): vol.All(cv.ensure_list, [ - { - vol.Required(CONF_LAT_NE): cv.latitude, - vol.Required(CONF_LAT_SW): cv.latitude, - vol.Required(CONF_LON_NE): cv.longitude, - vol.Required(CONF_LON_SW): cv.longitude, - vol.Required(CONF_MONITORED_CONDITIONS): [vol.In(SENSOR_TYPES)], - vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In(MODE_TYPES), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string - } - ]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the access to Netatmo binary sensor.""" - auth = hass.data[DATA_NETATMO_AUTH] - - sensors = [] - areas = config.get(CONF_AREAS) - for area_conf in areas: - data = NetatmoPublicData(auth, - lat_ne=area_conf.get(CONF_LAT_NE), - lon_ne=area_conf.get(CONF_LON_NE), - lat_sw=area_conf.get(CONF_LAT_SW), - lon_sw=area_conf.get(CONF_LON_SW)) - for sensor_type in area_conf.get(CONF_MONITORED_CONDITIONS): - sensors.append(NetatmoPublicSensor(area_conf.get(CONF_NAME), - data, sensor_type, - area_conf.get(CONF_MODE))) - add_entities(sensors, True) - - -class NetatmoPublicSensor(Entity): - """Represent a single sensor in a Netatmo.""" - - def __init__(self, area_name, data, sensor_type, mode): - """Initialize the sensor.""" - self.netatmo_data = data - self.type = sensor_type - self._mode = mode - self._name = '{} {}'.format(area_name, - SENSOR_TYPES[self.type][0]) - self._area_name = area_name - self._state = None - self._device_class = SENSOR_TYPES[self.type][3] - self._icon = SENSOR_TYPES[self.type][2] - self._unit_of_measurement = SENSOR_TYPES[self.type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - def update(self): - """Get the latest data from NetAtmo API and updates the states.""" - self.netatmo_data.update() - - if self.netatmo_data.data is None: - _LOGGER.warning("No data found for %s", self._name) - self._state = None - return - - data = None - - if self.type == 'temperature': - data = self.netatmo_data.data.getLatestTemperatures() - elif self.type == 'pressure': - data = self.netatmo_data.data.getLatestPressures() - elif self.type == 'humidity': - data = self.netatmo_data.data.getLatestHumidities() - elif self.type == 'rain': - data = self.netatmo_data.data.getLatestRain() - elif self.type == 'windstrength': - data = self.netatmo_data.data.getLatestWindStrengths() - elif self.type == 'guststrength': - data = self.netatmo_data.data.getLatestGustStrengths() - - if not data: - _LOGGER.warning("No station provides %s data in the area %s", - self.type, self._area_name) - self._state = None - return - - if self._mode == 'avg': - self._state = round(sum(data.values()) / len(data), 1) - elif self._mode == 'max': - self._state = max(data.values()) - - -class NetatmoPublicData: - """Get the latest data from NetAtmo.""" - - def __init__(self, auth, lat_ne, lon_ne, lat_sw, lon_sw): - """Initialize the data object.""" - self.auth = auth - self.data = None - self.lat_ne = lat_ne - self.lon_ne = lon_ne - self.lat_sw = lat_sw - self.lon_sw = lon_sw - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Request an update from the Netatmo API.""" - import pyatmo - data = pyatmo.PublicData(self.auth, - LAT_NE=self.lat_ne, - LON_NE=self.lon_ne, - LAT_SW=self.lat_sw, - LON_SW=self.lon_sw, - filtering=True) - - if data.CountStationInArea() == 0: - _LOGGER.warning('No Stations available in this area.') - return - - self.data = data diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 5491fffe969..0d349f8756e 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -35,11 +35,18 @@ DATA_KEY = 'netgear_lte' EVENT_SMS = 'netgear_lte_sms' SERVICE_DELETE_SMS = 'delete_sms' +SERVICE_SET_OPTION = 'set_option' +SERVICE_CONNECT_LTE = 'connect_lte' ATTR_HOST = 'host' ATTR_SMS_ID = 'sms_id' ATTR_FROM = 'from' ATTR_MESSAGE = 'message' +ATTR_FAILOVER = 'failover' +ATTR_AUTOCONNECT = 'autoconnect' + +FAILOVER_MODES = ['auto', 'wire', 'mobile'] +AUTOCONNECT_MODES = ['never', 'home', 'always'] NOTIFY_SCHEMA = vol.Schema({ @@ -74,10 +81,22 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) DELETE_SMS_SCHEMA = vol.Schema({ - vol.Required(ATTR_HOST): cv.string, + vol.Optional(ATTR_HOST): cv.string, vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), }) +SET_OPTION_SCHEMA = vol.Schema( + vol.All(cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), { + vol.Optional(ATTR_HOST): cv.string, + vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), + vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), + }) +) + +CONNECT_LTE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_HOST): cv.string, +}) + @attr.s class ModemData: @@ -116,7 +135,11 @@ class LTEData: def get_modem_data(self, config): """Get modem_data for the host in config.""" - return self.modem_data.get(config[CONF_HOST]) + if config[CONF_HOST] is not None: + return self.modem_data.get(config[CONF_HOST]) + if len(self.modem_data) != 1: + return None + return next(iter(self.modem_data.values())) async def async_setup(hass, config): @@ -126,24 +149,43 @@ async def async_setup(hass, config): hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) hass.data[DATA_KEY] = LTEData(websession) - async def delete_sms_handler(service): + async def service_handler(service): """Apply a service.""" - host = service.data[ATTR_HOST] + host = service.data.get(ATTR_HOST) conf = {CONF_HOST: host} modem_data = hass.data[DATA_KEY].get_modem_data(conf) if not modem_data: _LOGGER.error( - "%s: host %s unavailable", SERVICE_DELETE_SMS, host) + "%s: host %s unavailable", service.service, host) return - for sms_id in service.data[ATTR_SMS_ID]: - await modem_data.modem.delete_sms(sms_id) + if service.service == SERVICE_DELETE_SMS: + for sms_id in service.data[ATTR_SMS_ID]: + await modem_data.modem.delete_sms(sms_id) + elif service.service == SERVICE_SET_OPTION: + failover = service.data.get(ATTR_FAILOVER) + if failover: + await modem_data.modem.set_failover_mode(failover) + + autoconnect = service.data.get(ATTR_AUTOCONNECT) + if autoconnect: + await modem_data.modem.set_autoconnect_mode(autoconnect) + elif service.service == SERVICE_CONNECT_LTE: + await modem_data.modem.connect_lte() hass.services.async_register( - DOMAIN, SERVICE_DELETE_SMS, delete_sms_handler, + DOMAIN, SERVICE_DELETE_SMS, service_handler, schema=DELETE_SMS_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_SET_OPTION, service_handler, + schema=SET_OPTION_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_CONNECT_LTE, service_handler, + schema=CONNECT_LTE_SCHEMA) + netgear_lte_config = config[DOMAIN] # Set up each modem diff --git a/homeassistant/components/netgear_lte/services.yaml b/homeassistant/components/netgear_lte/services.yaml index 8f61e7a44b5..4ba3afb07b4 100644 --- a/homeassistant/components/netgear_lte/services.yaml +++ b/homeassistant/components/netgear_lte/services.yaml @@ -7,3 +7,23 @@ delete_sms: sms_id: description: Integer or list of integers with inbox IDs of messages to delete. example: 7 + +set_option: + description: Set options on the modem. + fields: + host: + description: The modem to set options on. + example: 192.168.5.1 + failover: + description: Failover mode, auto/wire/mobile. + example: auto + autoconnect: + description: Auto-connect mode, never/home/always. + example: home + +connect_lte: + description: Ask the modem to establish the LTE connection. + fields: + host: + description: The modem that should connect. + example: 192.168.5.1 diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py new file mode 100644 index 00000000000..4891af77b28 --- /dev/null +++ b/homeassistant/components/nextbus/__init__.py @@ -0,0 +1 @@ +"""NextBus sensor.""" diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json new file mode 100644 index 00000000000..63bdbf8a928 --- /dev/null +++ b/homeassistant/components/nextbus/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "nextbus", + "name": "NextBus", + "documentation": "https://www.home-assistant.io/components/nextbus", + "dependencies": [], + "codeowners": ["@vividboarder"], + "requirements": ["py_nextbus==0.1.2"] +} diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py new file mode 100644 index 00000000000..acf8028e31f --- /dev/null +++ b/homeassistant/components/nextbus/sensor.py @@ -0,0 +1,268 @@ +"""NextBus sensor.""" +import logging +from itertools import chain + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.entity import Entity +from homeassistant.util.dt import utc_from_timestamp + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'nextbus' + +CONF_AGENCY = 'agency' +CONF_ROUTE = 'route' +CONF_STOP = 'stop' + +ICON = 'mdi:bus' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_AGENCY): cv.string, + vol.Required(CONF_ROUTE): cv.string, + vol.Required(CONF_STOP): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +def listify(maybe_list): + """Return list version of whatever value is passed in. + + This is used to provide a consistent way of interacting with the JSON + results from the API. There are several attributes that will either missing + if there are no values, a single dictionary if there is only one value, and + a list if there are multiple. + """ + if maybe_list is None: + return [] + if isinstance(maybe_list, list): + return maybe_list + return [maybe_list] + + +def maybe_first(maybe_list): + """Return the first item out of a list or returns back the input.""" + if isinstance(maybe_list, list) and maybe_list: + return maybe_list[0] + + return maybe_list + + +def validate_value(value_name, value, value_list): + """Validate tag value is in the list of items and logs error if not.""" + valid_values = { + v['tag']: v['title'] + for v in value_list + } + if value not in valid_values: + _LOGGER.error( + 'Invalid %s tag `%s`. Please use one of the following: %s', + value_name, + value, + ', '.join( + '{}: {}'.format(title, tag) + for tag, title in valid_values.items() + ) + ) + return False + + return True + + +def validate_tags(client, agency, route, stop): + """Validate provided tags.""" + # Validate agencies + if not validate_value( + 'agency', + agency, + client.get_agency_list()['agency'], + ): + return False + + # Validate the route + if not validate_value( + 'route', + route, + client.get_route_list(agency)['route'], + ): + return False + + # Validate the stop + route_config = client.get_route_config(route, agency)['route'] + if not validate_value( + 'stop', + stop, + route_config['stop'], + ): + return False + + return True + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Load values from configuration and initialize the platform.""" + agency = config[CONF_AGENCY] + route = config[CONF_ROUTE] + stop = config[CONF_STOP] + name = config.get(CONF_NAME) + + from py_nextbus import NextBusClient + client = NextBusClient(output_format='json') + + # Ensures that the tags provided are valid, also logs out valid values + if not validate_tags(client, agency, route, stop): + _LOGGER.error('Invalid config value(s)') + return + + add_entities([ + NextBusDepartureSensor( + client, + agency, + route, + stop, + name, + ), + ], True) + + +class NextBusDepartureSensor(Entity): + """Sensor class that displays upcoming NextBus times. + + To function, this requires knowing the agency tag as well as the tags for + both the route and the stop. + + This is possibly a little convoluted to provide as it requires making a + request to the service to get these values. Perhaps it can be simplifed in + the future using fuzzy logic and matching. + """ + + def __init__(self, client, agency, route, stop, name=None): + """Initialize sensor with all required config.""" + self.agency = agency + self.route = route + self.stop = stop + self._custom_name = name + # Maybe pull a more user friendly name from the API here + self._name = '{} {}'.format(agency, route) + self._client = client + + # set up default state attributes + self._state = None + self._attributes = {} + + def _log_debug(self, message, *args): + """Log debug message with prefix.""" + _LOGGER.debug(':'.join(( + self.agency, + self.route, + self.stop, + message, + )), *args) + + @property + def name(self): + """Return sensor name. + + Uses an auto generated name based on the data from the API unless a + custom name is provided in the configuration. + """ + if self._custom_name: + return self._custom_name + + return self._name + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def state(self): + """Return current state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return additional state attributes.""" + return self._attributes + + @property + def icon(self): + """Return icon to be used for this sensor.""" + # Would be nice if we could determine if the line is a train or bus + # however that doesn't seem to be available to us. Using bus for now. + return ICON + + def update(self): + """Update sensor with new departures times.""" + # Note: using Multi because there is a bug with the single stop impl + results = self._client.get_predictions_for_multi_stops( + [{ + 'stop_tag': int(self.stop), + 'route_tag': self.route, + }], + self.agency, + ) + + self._log_debug('Predictions results: %s', results) + + if 'Error' in results: + self._log_debug('Could not get predictions: %s', results) + + if not results.get('predictions'): + self._log_debug('No predictions available') + self._state = None + # Remove attributes that may now be outdated + self._attributes.pop('upcoming', None) + return + + results = results['predictions'] + + # Set detailed attributes + self._attributes.update({ + 'agency': results.get('agencyTitle'), + 'route': results.get('routeTitle'), + 'stop': results.get('stopTitle'), + }) + + # List all messages in the attributes + messages = listify(results.get('message', [])) + self._log_debug('Messages: %s', messages) + self._attributes['message'] = ' -- '.join(( + message.get('text', '') + for message in messages + )) + + # List out all directions in the attributes + directions = listify(results.get('direction', [])) + self._attributes['direction'] = ', '.join(( + direction.get('title', '') + for direction in directions + )) + + # Chain all predictions together + predictions = list(chain(*[ + listify(direction.get('prediction', [])) + for direction in directions + ])) + + # Short circuit if we don't have any actual bus predictions + if not predictions: + self._log_debug('No upcoming predictions available') + self._state = None + self._attributes['upcoming'] = 'No upcoming predictions' + return + + # Generate list of upcoming times + self._attributes['upcoming'] = ', '.join( + p['minutes'] for p in predictions + ) + + latest_prediction = maybe_first(predictions) + self._state = utc_from_timestamp( + int(latest_prediction['epochTime']) / 1000 + ).isoformat() diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 3537f01b2b8..1b528b0af7e 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -47,7 +47,7 @@ def _arp(ip_address): out, _ = arp.communicate() match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out)) if match: - return match.group(0) + return ':'.join([i.zfill(2) for i in match.group(0).split(':')]) _LOGGER.info('No MAC address found for %s', ip_address) return None diff --git a/homeassistant/components/notify/manifest.json b/homeassistant/components/notify/manifest.json index 22c85723cb8..bad39a1cb97 100644 --- a/homeassistant/components/notify/manifest.json +++ b/homeassistant/components/notify/manifest.json @@ -5,6 +5,6 @@ "requirements": [], "dependencies": [], "codeowners": [ - "@flowolf" + "@home-assistant/core" ] } diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 60c2f9a231b..374e22d77dd 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -187,5 +187,5 @@ class OASATelematicsData(): return # Sort the data by time - sort = sorted(self.info, itemgetter(ATTR_NEXT_ARRIVAL)) + sort = sorted(self.info, key=itemgetter(ATTR_NEXT_ARRIVAL)) self.info = sort diff --git a/homeassistant/components/ohmconnect/manifest.json b/homeassistant/components/ohmconnect/manifest.json index a163a7d673a..33c93bc8ac1 100644 --- a/homeassistant/components/ohmconnect/manifest.json +++ b/homeassistant/components/ohmconnect/manifest.json @@ -3,7 +3,7 @@ "name": "Ohmconnect", "documentation": "https://www.home-assistant.io/components/ohmconnect", "requirements": [ - "defusedxml==0.5.0" + "defusedxml==0.6.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/onboarding/.translations/cs.json b/homeassistant/components/onboarding/.translations/cs.json new file mode 100644 index 00000000000..eeec05b9750 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/cs.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Lo\u017enice", + "kitchen": "Kuchyn\u011b", + "living_room": "Ob\u00fdv\u00e1k" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/en.json b/homeassistant/components/onboarding/.translations/en.json new file mode 100644 index 00000000000..aa591e7f1fa --- /dev/null +++ b/homeassistant/components/onboarding/.translations/en.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Bedroom", + "kitchen": "Kitchen", + "living_room": "Living Room" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/lb.json b/homeassistant/components/onboarding/.translations/lb.json new file mode 100644 index 00000000000..c5b139c913d --- /dev/null +++ b/homeassistant/components/onboarding/.translations/lb.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Schlofkummer", + "kitchen": "Kichen", + "living_room": "Stuff" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/ru.json b/homeassistant/components/onboarding/.translations/ru.json new file mode 100644 index 00000000000..ffed30dd6b8 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/ru.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "\u0421\u043f\u0430\u043b\u044c\u043d\u044f", + "kitchen": "\u041a\u0443\u0445\u043d\u044f", + "living_room": "\u0413\u043e\u0441\u0442\u0438\u043d\u0430\u044f" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/zh-Hant.json b/homeassistant/components/onboarding/.translations/zh-Hant.json new file mode 100644 index 00000000000..673d099158f --- /dev/null +++ b/homeassistant/components/onboarding/.translations/zh-Hant.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "\u81e5\u5ba4", + "kitchen": "\u5eda\u623f", + "living_room": "\u5ba2\u5ef3" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 29371369c70..55bba8f4efe 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -1,24 +1,42 @@ """Support to help onboard new users.""" from homeassistant.core import callback from homeassistant.loader import bind_hass +from homeassistant.helpers.storage import Store -from .const import DOMAIN, STEP_USER, STEPS +from .const import DOMAIN, STEP_USER, STEPS, STEP_INTEGRATION STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 +STORAGE_VERSION = 2 + + +class OnboadingStorage(Store): + """Store onboarding data.""" + + async def _async_migrate_func(self, old_version, old_data): + """Migrate to the new version.""" + # From version 1 -> 2, we automatically mark the integration step done + old_data['done'].append(STEP_INTEGRATION) + return old_data @bind_hass @callback def async_is_onboarded(hass): """Return if Home Assistant has been onboarded.""" - return hass.data.get(DOMAIN, True) + data = hass.data.get(DOMAIN) + return data is None or data is True + + +@bind_hass +@callback +def async_is_user_onboarded(hass): + """Return if a user has been created as part of onboarding.""" + return async_is_onboarded(hass) or STEP_USER in hass.data[DOMAIN]['done'] async def async_setup(hass, config): """Set up the onboarding component.""" - store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True) + store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) data = await store.async_load() if data is None: @@ -43,7 +61,7 @@ async def async_setup(hass, config): if set(data['done']) == set(STEPS): return True - hass.data[DOMAIN] = False + hass.data[DOMAIN] = data from . import views diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py index 3aa106ac18c..fe1b28fc316 100644 --- a/homeassistant/components/onboarding/const.py +++ b/homeassistant/components/onboarding/const.py @@ -1,7 +1,15 @@ """Constants for the onboarding component.""" DOMAIN = 'onboarding' STEP_USER = 'user' +STEP_INTEGRATION = 'integration' STEPS = [ - STEP_USER + STEP_USER, + STEP_INTEGRATION, ] + +DEFAULT_AREAS = ( + 'living_room', + 'kitchen', + 'bedroom', +) diff --git a/homeassistant/components/onboarding/strings.json b/homeassistant/components/onboarding/strings.json new file mode 100644 index 00000000000..9e3806927d2 --- /dev/null +++ b/homeassistant/components/onboarding/strings.json @@ -0,0 +1,7 @@ +{ + "area": { + "living_room": "Living Room", + "bedroom": "Bedroom", + "kitchen": "Kitchen" + } +} diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index d9631b77a20..a156fe4676f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -7,13 +7,14 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import callback -from .const import DOMAIN, STEP_USER, STEPS +from .const import DOMAIN, STEP_USER, STEPS, DEFAULT_AREAS, STEP_INTEGRATION async def async_setup(hass, data, store): """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(UserOnboardingView(data, store)) + hass.http.register_view(IntegrationOnboardingView(data, store)) class OnboardingView(HomeAssistantView): @@ -41,7 +42,6 @@ class OnboardingView(HomeAssistantView): class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" - requires_auth = False step = None def __init__(self, data, store): @@ -60,14 +60,16 @@ class _BaseOnboardingView(HomeAssistantView): self._data['done'].append(self.step) await self._store.async_save(self._data) - hass.data[DOMAIN] = len(self._data) == len(STEPS) + if set(self._data['done']) == set(STEPS): + hass.data[DOMAIN] = True class UserOnboardingView(_BaseOnboardingView): - """View to handle onboarding.""" + """View to handle create user onboarding step.""" url = '/api/onboarding/users' name = 'api:onboarding:users' + requires_auth = False step = STEP_USER @RequestDataValidator(vol.Schema({ @@ -75,9 +77,10 @@ class UserOnboardingView(_BaseOnboardingView): vol.Required('username'): str, vol.Required('password'): str, vol.Required('client_id'): str, + vol.Required('language'): str, })) async def post(self, request, data): - """Return the manifest.json.""" + """Handle user creation, area creation.""" hass = request.app['hass'] async with self._lock: @@ -100,14 +103,58 @@ class UserOnboardingView(_BaseOnboardingView): data['name'], user_id=user.id ) + # Create default areas using the users supplied language. + translations = \ + await hass.helpers.translation.async_get_translations( + data['language']) + + area_registry = \ + await hass.helpers.area_registry.async_get_registry() + + for area in DEFAULT_AREAS: + area_registry.async_create( + translations['component.onboarding.area.{}'.format(area)] + ) + await self._async_mark_done(hass) - # Return an authorization code to allow fetching tokens. + # Return authorization code for fetching tokens and connect + # during onboarding. auth_code = hass.components.auth.create_auth_code( data['client_id'], user ) return self.json({ - 'auth_code': auth_code + 'auth_code': auth_code, + }) + + +class IntegrationOnboardingView(_BaseOnboardingView): + """View to finish integration onboarding step.""" + + url = '/api/onboarding/integration' + name = 'api:onboarding:integration' + step = STEP_INTEGRATION + + @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, + })) + async def post(self, request, data): + """Handle user creation, area creation.""" + hass = request.app['hass'] + user = request['hass_user'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('Integration step already done', 403) + + await self._async_mark_done(hass) + + # Return authorization code so we can redirect user and log them in + auth_code = hass.components.auth.create_auth_code( + data['client_id'], user + ) + return self.json({ + 'auth_code': auth_code, }) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 6a773a854c9..ea3d0277136 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,8 +1,13 @@ -"""Support for ONVIF Cameras with FFmpeg as decoder.""" +""" +Support for ONVIF Cameras with FFmpeg as decoder. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.onvif/ +""" import asyncio +import datetime as dt import logging import os - import voluptuous as vol from homeassistant.const import ( @@ -65,9 +70,12 @@ SERVICE_PTZ_SCHEMA = vol.Schema({ }) -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 a ONVIF camera.""" - def handle_ptz(service): + _LOGGER.debug("Setting up the ONVIF camera platform") + + async def async_handle_ptz(service): """Handle PTZ service call.""" pan = service.data.get(ATTR_PAN, None) tilt = service.data.get(ATTR_TILT, None) @@ -81,20 +89,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): target_cameras = [camera for camera in all_cameras if camera.entity_id in entity_ids] for camera in target_cameras: - camera.perform_ptz(pan, tilt, zoom) + await camera.async_perform_ptz(pan, tilt, zoom) - hass.services.register(DOMAIN, SERVICE_PTZ, handle_ptz, - schema=SERVICE_PTZ_SCHEMA) - add_entities([ONVIFHassCamera(hass, config)]) + hass.services.async_register(DOMAIN, SERVICE_PTZ, async_handle_ptz, + schema=SERVICE_PTZ_SCHEMA) + + _LOGGER.debug("Constructing the ONVIFHassCamera") + + hass_camera = ONVIFHassCamera(hass, config) + + await hass_camera.async_initialize() + + async_add_entities([hass_camera]) + return class ONVIFHassCamera(Camera): """An implementation of an ONVIF camera.""" def __init__(self, hass, config): - """Initialize a ONVIF camera.""" + """Initialize an ONVIF camera.""" super().__init__() + + _LOGGER.debug("Importing dependencies") + import onvif + from onvif import ONVIFCamera + + _LOGGER.debug("Setting up the ONVIF camera component") self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) @@ -103,29 +125,105 @@ class ONVIFHassCamera(Camera): self._name = config.get(CONF_NAME) self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) self._profile_index = config.get(CONF_PROFILE) + self._ptz_service = None self._input = None - self._media_service = \ - onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( - self._host, self._port), - self._username, self._password, - '{}/wsdl/media.wsdl'.format(os.path.dirname( - onvif.__file__))) - self._ptz_service = \ - onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( - self._host, self._port), - self._username, self._password, - '{}/wsdl/ptz.wsdl'.format(os.path.dirname( - onvif.__file__))) + _LOGGER.debug("Setting up the ONVIF camera device @ '%s:%s'", + self._host, + self._port) - def obtain_input_uri(self): + self._camera = ONVIFCamera(self._host, + self._port, + self._username, + self._password, + '{}/wsdl/' + .format(os.path.dirname(onvif.__file__))) + + async def async_initialize(self): + """ + Initialize the camera. + + Initializes the camera by obtaining the input uri and connecting to + the camera. Also retrieves the ONVIF profiles. + """ + from aiohttp.client_exceptions import ClientConnectorError + from homeassistant.exceptions import PlatformNotReady + from zeep.exceptions import Fault + import homeassistant.util.dt as dt_util + + try: + _LOGGER.debug("Updating service addresses") + + await self._camera.update_xaddrs() + + _LOGGER.debug("Setting up the ONVIF device management service") + + devicemgmt = self._camera.create_devicemgmt_service() + + _LOGGER.debug("Retrieving current camera date/time") + + system_date = dt_util.utcnow() + device_time = await devicemgmt.GetSystemDateAndTime() + cdate = device_time.UTCDateTime + cam_date = dt.datetime(cdate.Date.Year, cdate.Date.Month, + cdate.Date.Day, cdate.Time.Hour, + cdate.Time.Minute, cdate.Time.Second, + 0, dt_util.UTC) + + _LOGGER.debug("Camera date/time: %s", + cam_date) + + _LOGGER.debug("System date/time: %s", + system_date) + + dt_diff = cam_date - system_date + dt_diff_seconds = dt_diff.total_seconds() + + if dt_diff_seconds > 5: + _LOGGER.warning("The date/time on the camera is '%s', " + "which is different from the system '%s', " + "this could lead to authentication issues", + cam_date, + system_date) + + _LOGGER.debug("Obtaining input uri") + + await self.async_obtain_input_uri() + + _LOGGER.debug("Setting up the ONVIF PTZ service") + + if self._camera.get_service('ptz', create=False) is None: + _LOGGER.warning("PTZ is not available on this camera") + else: + self._ptz_service = self._camera.create_ptz_service() + _LOGGER.debug("Completed set up of the ONVIF camera component") + except ClientConnectorError as err: + _LOGGER.warning("Couldn't connect to camera '%s', but will " + "retry later. Error: %s", + self._name, err) + raise PlatformNotReady + except Fault as err: + _LOGGER.error("Couldn't connect to camera '%s', please verify " + "that the credentials are correct. Error: %s", + self._name, err) + return + + async def async_obtain_input_uri(self): """Set the input uri for the camera.""" from onvif import exceptions + _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", self._host, self._port) try: - profiles = self._media_service.GetProfiles() + _LOGGER.debug("Retrieving profiles") + + media_service = self._camera.create_media_service() + + profiles = await media_service.GetProfiles() + + _LOGGER.debug("Retrieved '%d' profiles", + len(profiles)) if self._profile_index >= len(profiles): _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d." @@ -133,29 +231,41 @@ class ONVIFHassCamera(Camera): self._name, self._profile_index) self._profile_index = -1 - req = self._media_service.create_type('GetStreamUri') + _LOGGER.debug("Using profile index '%d'", + self._profile_index) - # pylint: disable=protected-access - req.ProfileToken = profiles[self._profile_index]._token - uri_no_auth = self._media_service.GetStreamUri(req).Uri + _LOGGER.debug("Retrieving stream uri") + + req = media_service.create_type('GetStreamUri') + req.ProfileToken = profiles[self._profile_index].token + req.StreamSetup = {'Stream': 'RTP-Unicast', + 'Transport': {'Protocol': 'RTSP'}} + + stream_uri = await media_service.GetStreamUri(req) + uri_no_auth = stream_uri.Uri uri_for_log = uri_no_auth.replace( 'rtsp://', 'rtsp://:@', 1) self._input = uri_no_auth.replace( 'rtsp://', 'rtsp://{}:{}@'.format(self._username, self._password), 1) + _LOGGER.debug( "ONVIF Camera Using the following URL for %s: %s", self._name, uri_for_log) - # we won't need the media service anymore - self._media_service = None except exceptions.ONVIFError as err: - _LOGGER.debug("Couldn't setup camera '%s'. Error: %s", + _LOGGER.error("Couldn't setup camera '%s'. Error: %s", self._name, err) return - def perform_ptz(self, pan, tilt, zoom): + async def async_perform_ptz(self, pan, tilt, zoom): """Perform a PTZ action on the camera.""" from onvif import exceptions + + if self._ptz_service is None: + _LOGGER.warning("PTZ actions are not supported on camera '%s'", + self._name) + return + if self._ptz_service: pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0 tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0 @@ -164,7 +274,11 @@ class ONVIFHassCamera(Camera): "PanTilt": {"_x": pan_val, "_y": tilt_val}, "Zoom": {"_x": zoom_val}}} try: - self._ptz_service.ContinuousMove(req) + _LOGGER.debug( + "Calling PTZ | Pan = %d | Tilt = %d | Zoom = %d", + pan_val, tilt_val, zoom_val) + + await self._ptz_service.ContinuousMove(req) except exceptions.ONVIFError as err: if "Bad Request" in err.reason: self._ptz_service = None @@ -175,20 +289,18 @@ class ONVIFHassCamera(Camera): async def async_added_to_hass(self): """Handle entity addition to hass.""" + _LOGGER.debug("Camera '%s' added to hass", self._name) + if ONVIF_DATA not in self.hass.data: self.hass.data[ONVIF_DATA] = {} self.hass.data[ONVIF_DATA][ENTITIES] = [] self.hass.data[ONVIF_DATA][ENTITIES].append(self) - await self.hass.async_add_executor_job(self.obtain_input_uri) async def async_camera_image(self): """Return a still image response from the camera.""" from haffmpeg.tools import ImageFrame, IMAGE_JPEG - if not self._input: - await self.hass.async_add_executor_job(self.obtain_input_uri) - if not self._input: - return None + _LOGGER.debug("Retrieving image from camera '%s'", self._name) ffmpeg = ImageFrame( self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop) @@ -202,14 +314,12 @@ class ONVIFHassCamera(Camera): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg.camera import CameraMjpeg - if not self._input: - await self.hass.async_add_executor_job(self.obtain_input_uri) - if not self._input: - return None + _LOGGER.debug("Handling mjpeg stream from camera '%s'", self._name) ffmpeg_manager = self.hass.data[DATA_FFMPEG] stream = CameraMjpeg(ffmpeg_manager.binary, loop=self.hass.loop) + await stream.open_camera( self._input, extra_cmd=self._ffmpeg_arguments) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index bade9f37022..d86ec38ccb7 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -3,12 +3,10 @@ "name": "Onvif", "documentation": "https://www.home-assistant.io/components/onvif", "requirements": [ - "onvif-py3==0.1.3", - "suds-passworddigest-homeassistant==0.1.2a0.dev0", - "suds-py3==1.3.3.0" + "onvif-zeep-async==0.2.0" ], "dependencies": [ "ffmpeg" ], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index b49e5b73554..dfc493f1c96 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -3,7 +3,7 @@ "name": "Opencv", "documentation": "https://www.home-assistant.io/components/opencv", "requirements": [ - "numpy==1.16.2" + "numpy==1.16.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 63d2744cd4d..01f722f0bf6 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -162,7 +162,13 @@ async def async_setup_entry(hass, config_entry): async def update_data(service): """Refresh OpenUV data.""" _LOGGER.debug('Refreshing OpenUV data') - await openuv.async_update() + + try: + await openuv.async_update() + except OpenUvError as err: + _LOGGER.error('Error during data update: %s', err) + return + async_dispatcher_send(hass, TOPIC_UPDATE) hass.services.async_register(DOMAIN, 'update_data', update_data) diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index c3d66c52663..1e0f8f826ab 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -1,5 +1,4 @@ """Support for the Opple light.""" - import logging import voluptuous as vol @@ -9,10 +8,9 @@ from homeassistant.components.light import ( SUPPORT_COLOR_TEMP, Light) from homeassistant.const import CONF_HOST, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.util.color import \ - color_temperature_kelvin_to_mired as kelvin_to_mired -from homeassistant.util.color import \ - color_temperature_mired_to_kelvin as mired_to_kelvin +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired as kelvin_to_mired, + color_temperature_mired_to_kelvin as mired_to_kelvin) _LOGGER = logging.getLogger(__name__) @@ -20,15 +18,16 @@ DEFAULT_NAME = "opple light" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Opple light platform.""" + """Set up the Opple light platform.""" name = config[CONF_NAME] host = config[CONF_HOST] entity = OppleLight(name, host) + add_entities([entity]) _LOGGER.debug("Init light %s %s", host, entity.unique_id) diff --git a/homeassistant/components/orangepi_gpio/__init__.py b/homeassistant/components/orangepi_gpio/__init__.py new file mode 100644 index 00000000000..79ebf01ed61 --- /dev/null +++ b/homeassistant/components/orangepi_gpio/__init__.py @@ -0,0 +1,81 @@ +"""Support for controlling GPIO pins of a Orange Pi.""" +import logging + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +_LOGGER = logging.getLogger(__name__) + +CONF_PIN_MODE = 'pin_mode' +DOMAIN = 'orangepi_gpio' +PIN_MODES = ['pc', 'zeroplus', 'zeroplus2', 'deo', 'neocore2'] + + +def setup(hass, config): + """Set up the Orange Pi GPIO component.""" + from OPi import GPIO + + def cleanup_gpio(event): + """Stuff to do before stopping.""" + GPIO.cleanup() + + def prepare_gpio(event): + """Stuff to do when home assistant starts.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) + return True + + +def setup_mode(mode): + """Set GPIO pin mode.""" + from OPi import GPIO + + if mode == 'pc': + import orangepi.pc + GPIO.setmode(orangepi.pc.BOARD) + elif mode == 'zeroplus': + import orangepi.zeroplus + GPIO.setmode(orangepi.zeroplus.BOARD) + elif mode == 'zeroplus2': + import orangepi.zeroplus + GPIO.setmode(orangepi.zeroplus2.BOARD) + elif mode == 'duo': + import nanopi.duo + GPIO.setmode(nanopi.duo.BOARD) + elif mode == 'neocore2': + import nanopi.neocore2 + GPIO.setmode(nanopi.neocore2.BOARD) + + +def setup_output(port): + """Set up a GPIO as output.""" + from OPi import GPIO + GPIO.setup(port, GPIO.OUT) + + +def setup_input(port): + """Set up a GPIO as input.""" + from OPi import GPIO + GPIO.setup(port, GPIO.IN) + + +def write_output(port, value): + """Write a value to a GPIO.""" + from OPi import GPIO + GPIO.output(port, value) + + +def read_input(port): + """Read a value from a GPIO.""" + from OPi import GPIO + return GPIO.input(port) + + +def edge_detect(port, event_callback): + """Add detection for RISING and FALLING events.""" + from OPi import GPIO + GPIO.add_event_detect( + port, + GPIO.BOTH, + callback=event_callback) diff --git a/homeassistant/components/orangepi_gpio/binary_sensor.py b/homeassistant/components/orangepi_gpio/binary_sensor.py new file mode 100644 index 00000000000..10eddb1e041 --- /dev/null +++ b/homeassistant/components/orangepi_gpio/binary_sensor.py @@ -0,0 +1,68 @@ +"""Support for binary sensor using Orange Pi GPIO.""" +import logging + +from homeassistant.components import orangepi_gpio +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import DEVICE_DEFAULT_NAME + +from . import CONF_PIN_MODE +from .const import CONF_INVERT_LOGIC, CONF_PORTS, PORT_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(PORT_SCHEMA) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Orange Pi GPIO devices.""" + pin_mode = config[CONF_PIN_MODE] + orangepi_gpio.setup_mode(pin_mode) + + invert_logic = config[CONF_INVERT_LOGIC] + + binary_sensors = [] + ports = config[CONF_PORTS] + for port_num, port_name in ports.items(): + binary_sensors.append(OPiGPIOBinarySensor( + port_name, port_num, invert_logic)) + add_entities(binary_sensors, True) + + +class OPiGPIOBinarySensor(BinarySensorDevice): + """Represent a binary sensor that uses Orange Pi GPIO.""" + + def __init__(self, name, port, invert_logic): + """Initialize the Orange Pi binary sensor.""" + self._name = name or DEVICE_DEFAULT_NAME + self._port = port + self._invert_logic = invert_logic + self._state = None + + orangepi_gpio.setup_input(self._port) + + def read_gpio(port): + """Read state from GPIO.""" + self._state = orangepi_gpio.read_input(self._port) + self.schedule_update_ha_state() + + orangepi_gpio.edge_detect(self._port, read_gpio) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state != self._invert_logic + + def update(self): + """Update the GPIO state.""" + self._state = orangepi_gpio.read_input(self._port) diff --git a/homeassistant/components/orangepi_gpio/const.py b/homeassistant/components/orangepi_gpio/const.py new file mode 100644 index 00000000000..373df656b25 --- /dev/null +++ b/homeassistant/components/orangepi_gpio/const.py @@ -0,0 +1,21 @@ +"""Constants for Orange Pi GPIO.""" +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from . import CONF_PIN_MODE, PIN_MODES + +CONF_INVERT_LOGIC = 'invert_logic' +CONF_PORTS = 'ports' + +DEFAULT_INVERT_LOGIC = False + +_SENSORS_SCHEMA = vol.Schema({ + cv.positive_int: cv.string, +}) + +PORT_SCHEMA = { + vol.Required(CONF_PORTS): _SENSORS_SCHEMA, + vol.Required(CONF_PIN_MODE): vol.In(PIN_MODES), + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, +} diff --git a/homeassistant/components/orangepi_gpio/manifest.json b/homeassistant/components/orangepi_gpio/manifest.json new file mode 100644 index 00000000000..65fd0f7de50 --- /dev/null +++ b/homeassistant/components/orangepi_gpio/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "orangepi_gpio", + "name": "Orangepi GPIO", + "documentation": "https://www.home-assistant.io/components/orangepi_gpio", + "requirements": [ + "OPi.GPIO==0.3.6" + ], + "dependencies": [], + "codeowners": [ + "@pascallj" + ] +} diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json index 3eb24e0f1c6..cea246af328 100644 --- a/homeassistant/components/otp/manifest.json +++ b/homeassistant/components/otp/manifest.json @@ -3,7 +3,7 @@ "name": "Otp", "documentation": "https://www.home-assistant.io/components/otp", "requirements": [ - "pyotp==2.2.6" + "pyotp==2.2.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/owntracks/.translations/es.json b/homeassistant/components/owntracks/.translations/es.json index f866aa6e403..f5398c1c399 100644 --- a/homeassistant/components/owntracks/.translations/es.json +++ b/homeassistant/components/owntracks/.translations/es.json @@ -4,7 +4,7 @@ "one_instance_allowed": "Solo se necesita una instancia." }, "create_entry": { - "default": "\n\nEn Android, abra[la aplicaci\u00f3n OwnTracks]({android_url}), vaya a Preferencias -> Conexi\u00f3n. Cambie los siguientes ajustes:\n - Mode: HTTP privado\n - URL: {webhook_url}\n - Identificaci\u00f3n:\n - Nombre de usuario: \n - ID de dispositivo: \n\nEn iOS, abra[la aplicaci\u00f3n OwnTracks] ({ios_url}), toque el icono (i) en la parte superior izquierda -> configuraci\u00f3n. Cambie los siguientes ajustes:\n - Mode: HTTP\n - URL: {webhook_url}\n - Activar la autenticaci\u00f3n\n - UserID: \n\n{secret}\n\nConsulte[la documentaci\u00f3n]({docs_url}) para obtener m\u00e1s informaci\u00f3n." + "default": "\n\nEn Android, abre [la aplicaci\u00f3n OwnTracks]({android_url}), ve a preferencias -> conexi\u00f3n. Cambia los siguientes ajustes:\n - Modo: HTTP privado\n - Host: {webhook_url}\n - Identificaci\u00f3n:\n - Nombre de usuario: \n - ID de dispositivo: \n\nEn iOS, abre [la aplicaci\u00f3n OwnTracks] ({ios_url}), pulsa el icono (i) en la parte superior izquierda -> configuraci\u00f3n. Cambia los siguientes ajustes:\n - Modo: HTTP\n - URL: {webhook_url}\n - Activar la autenticaci\u00f3n\n - UserID: \n\n{secret}\n\nConsulta [la documentaci\u00f3n]({docs_url}) para obtener m\u00e1s informaci\u00f3n." }, "step": { "user": { diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index 9da5cf87e53..cfcb9b5a380 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -1,4 +1,4 @@ -"""Support for Panasonic Blu-Ray players.""" +"""Support for Panasonic Blu-ray players.""" from datetime import timedelta import logging @@ -14,15 +14,15 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -DEFAULT_NAME = "Panasonic Blu-Ray" -SCAN_INTERVAL = timedelta(seconds=30) - _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = "Panasonic Blu-Ray" + +SCAN_INTERVAL = timedelta(seconds=30) + SUPPORT_PANASONIC_BD = (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_PAUSE) -# No host is needed for configuration, however it can be set. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Panasonic Blu-Ray platform.""" + """Set up the Panasonic Blu-ray platform.""" conf = discovery_info if discovery_info else config # Register configured device with Home Assistant. @@ -38,18 +38,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class PanasonicBluRay(MediaPlayerDevice): - """Represent Panasonic Blu-Ray devices for Home Assistant.""" + """Representation of a Panasonic Blu-ray device.""" def __init__(self, ip, name): - """Receive IP address and name to construct class.""" - # Import panacotta library. + """Initialize the Panasonic Blue-ray device.""" import panacotta - # Initialize the Panasonic device. self._device = panacotta.PanasonicBD(ip) - # Default name value, only to be overridden by user. self._name = name - # Assume we're off to start with self._state = STATE_OFF self._position = 0 self._duration = 0 diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index ca73c6d56bb..496ab9199c3 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -17,3 +17,10 @@ dismiss: notification_id: description: Target ID of the notification, which should be removed. [Required] example: 1234 + +mark_read: + description: Mark a notification read. + fields: + notification_id: + description: Target ID of the notification, which should be mark read. [Required] + example: 1234 diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 18ddcf1f5ff..0b1579a139d 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -3,8 +3,8 @@ "name": "Philips js", "documentation": "https://www.home-assistant.io/components/philips_js", "requirements": [ - "ha-philipsjs==0.0.5" + "ha-philipsjs==0.0.8" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@elupus"] } diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 859ad26a3dd..743992990ca 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -7,40 +7,48 @@ import voluptuous as vol from homeassistant.components.media_player import ( MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( - SUPPORT_NEXT_TRACK, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + MEDIA_TYPE_CHANNEL, SUPPORT_PLAY_MEDIA) from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import call_later, track_time_interval from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=30) - SUPPORT_PHILIPS_JS = SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_SELECT_SOURCE - -SUPPORT_PHILIPS_JS_TV = SUPPORT_PHILIPS_JS | SUPPORT_NEXT_TRACK | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY_MEDIA CONF_ON_ACTION = 'turn_on_action' -DEFAULT_DEVICE = 'default' -DEFAULT_HOST = '127.0.0.1' DEFAULT_NAME = "Philips TV" DEFAULT_API_VERSION = '1' +DEFAULT_SCAN_INTERVAL = 30 + +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, default=DEFAULT_HOST): cv.string, + 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, }) +def _inverted(data): + return {v: k for k, v in data.items()} + + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Philips TV platform.""" import haphilipsjs @@ -63,18 +71,38 @@ class PhilipsTV(MediaPlayerDevice): """Initialize the Philips TV.""" self._tv = tv self._name = name - self._state = None - self._volume = None - self._muted = False - self._program_name = None - self._channel_name = None - self._source = None - self._source_list = [] - self._connfail = 0 - self._source_mapping = {} - self._watching_tv = None - self._channel_name = None + 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 + + def _update_soon(self, delay): + """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) @property def name(self): @@ -84,110 +112,171 @@ class PhilipsTV(MediaPlayerDevice): @property def should_poll(self): """Device should be polled.""" - return True + return False @property def supported_features(self): """Flag media player features that are supported.""" - is_supporting_turn_on = SUPPORT_TURN_ON if self._on_script else 0 - if self._watching_tv: - return SUPPORT_PHILIPS_JS_TV | is_supporting_turn_on - return SUPPORT_PHILIPS_JS | is_supporting_turn_on + return self._supports @property def state(self): """Get the device state. An exception means OFF state.""" - return self._state + if self._tv.on: + return STATE_ON + return STATE_OFF @property def source(self): """Return the current input source.""" - return self._source + if self.media_content_type == MEDIA_TYPE_CHANNEL: + name = self._channels.get(self._tv.channel_id) + prefix = PREFIX_CHANNEL + else: + name = self._sources.get(self._tv.source_id) + prefix = PREFIX_SOURCE + + if name is None: + return None + return prefix + PREFIX_SEPARATOR + name @property def source_list(self): """List of available input sources.""" - return self._source_list + complete = [] + for source in self._sources.values(): + complete.append(PREFIX_SOURCE + PREFIX_SEPARATOR + source) + for channel in self._channels.values(): + complete.append(PREFIX_CHANNEL + PREFIX_SEPARATOR + channel) + return complete def select_source(self, source): """Set the input source.""" - if source in self._source_mapping: - self._tv.setSource(self._source_mapping.get(source)) + data = source.split(PREFIX_SEPARATOR, 1) + if data[0] == PREFIX_SOURCE: + source_id = _inverted(self._sources).get(data[1]) + if source_id: + self._tv.setSource(source_id) + elif data[0] == PREFIX_CHANNEL: + channel_id = _inverted(self._channels).get(data[1]) + if channel_id: + self._tv.setChannel(channel_id) + self._update_soon(DELAY_ACTION_DEFAULT) @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._volume + return self._tv.volume @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return self._muted + return self._tv.muted def turn_on(self): """Turn on the device.""" if self._on_script: self._on_script.run() + self._update_soon(DELAY_ACTION_ON) def turn_off(self): """Turn off the device.""" self._tv.sendKey('Standby') + self._tv.on = False + self._update_soon(DELAY_ACTION_DEFAULT) def volume_up(self): """Send volume up command.""" self._tv.sendKey('VolumeUp') + self._update_soon(DELAY_ACTION_DEFAULT) def volume_down(self): """Send volume down command.""" self._tv.sendKey('VolumeDown') + self._update_soon(DELAY_ACTION_DEFAULT) def mute_volume(self, mute): """Send mute command.""" - if self._muted != mute: - self._tv.sendKey('Mute') - self._muted = mute + self._tv.setVolume(None, mute) + self._update_soon(DELAY_ACTION_DEFAULT) def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._tv.setVolume(volume) + self._tv.setVolume(volume, self._tv.muted) + self._update_soon(DELAY_ACTION_DEFAULT) def media_previous_track(self): """Send rewind command.""" self._tv.sendKey('Previous') + self._update_soon(DELAY_ACTION_DEFAULT) def media_next_track(self): """Send fast forward command.""" self._tv.sendKey('Next') + self._update_soon(DELAY_ACTION_DEFAULT) + + @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 @property def media_title(self): """Title of current playing media.""" - if self._watching_tv and self._channel_name: - return '{} - {}'.format(self._source, self._channel_name) - return self._source + if self.media_content_type == MEDIA_TYPE_CHANNEL: + return self._channels.get(self._tv.channel_id) + return self._sources.get(self._tv.source_id) + + @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 + + @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 None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'channel_list': list(self._channels.values()) + } + + def 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) + if channel_id: + self._tv.setChannel(channel_id) + self._update_soon(DELAY_ACTION_DEFAULT) + else: + _LOGGER.error("Unable to find channel <%s>", media_id) + else: + _LOGGER.error("Unsupported media type <%s>", media_type) def update(self): """Get the latest data and update device state.""" self._tv.update() - self._volume = self._tv.volume - self._muted = self._tv.muted - if self._tv.source_id: - self._source = self._tv.getSourceName(self._tv.source_id) - if self._tv.sources and not self._source_list: - for srcid in self._tv.sources: - srcname = self._tv.getSourceName(srcid) - self._source_list.append(srcname) - self._source_mapping[srcname] = srcid - if self._tv.on: - self._state = STATE_ON - else: - self._state = STATE_OFF - self._watching_tv = bool(self._tv.source_id == 'tv') + self._sources = { + srcid: source['name'] or "Source {}".format(srcid) + for srcid, source in (self._tv.sources or {}).items() + } - self._tv.getChannelId() - self._tv.getChannels() - if self._tv.channels and self._tv.channel_id in self._tv.channels: - self._channel_name = self._tv.channels[self._tv.channel_id]['name'] - else: - self._channel_name = None + self._channels = { + chid: channel['name'] + for chid, channel in (self._tv.channels or {}).items() + } diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 4cb4204f274..4a65808e049 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -172,12 +172,15 @@ def setup_plexserver( # add devices with a session and no client (ex. PlexConnect Apple TV's) if config.get(CONF_INCLUDE_NON_CLIENTS): - for machine_identifier, (session, player) in plex_sessions.items(): + # To avoid errors when plex sessions created during iteration + sessions = list(plex_sessions.items()) + for machine_identifier, (session, player) in sessions: if machine_identifier in available_client_ids: # Avoid using session if already added as a device. _LOGGER.debug("Skipping session, device exists: %s", machine_identifier) continue + if (machine_identifier not in plex_clients and machine_identifier is not None): new_client = PlexClient( diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json index 789068a6339..fd603aa0430 100644 --- a/homeassistant/components/point/.translations/ca.json +++ b/homeassistant/components/point/.translations/ca.json @@ -11,12 +11,12 @@ "default": "Autenticaci\u00f3 exitosa amb Minut per als teus dispositiu/s Point." }, "error": { - "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Enviar", + "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia", "no_token": "No s'ha autenticat amb Minut" }, "step": { "auth": { - "description": "V\u00e9s a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al teu compte de Minut, despr\u00e9s torna i prem Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", + "description": "V\u00e9s a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al teu compte de Minut, despr\u00e9s torna i prem Envia (a sota). \n\n[Enlla\u00e7]({authorization_url})", "title": "Autenticar Point" }, "user": { diff --git a/homeassistant/components/point/.translations/es.json b/homeassistant/components/point/.translations/es.json index 1d092c28b64..33b6b1d3827 100644 --- a/homeassistant/components/point/.translations/es.json +++ b/homeassistant/components/point/.translations/es.json @@ -3,6 +3,7 @@ "abort": { "already_setup": "S\u00f3lo se puede configurar una cuenta de Point.", "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n", "external_setup": "Point se ha configurado correctamente a partir de otro flujo.", "no_flows": "Es necesario configurar Point antes de poder autenticarse con \u00e9l. [Echa un vistazo a las instrucciones] (https://www.home-assistant.io/components/point/)." }, @@ -25,6 +26,7 @@ "description": "Elige a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n quieres autenticarte con Point.", "title": "Proveedor de autenticaci\u00f3n" } - } + }, + "title": "Point de Minut" } } \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json index 3e2bbc4df65..2a10b234e99 100644 --- a/homeassistant/components/point/.translations/ru.json +++ b/homeassistant/components/point/.translations/ru.json @@ -16,7 +16,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\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \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\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c.", + "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\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \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.", "title": "Minut Point" }, "user": { diff --git a/homeassistant/components/pollen/__init__.py b/homeassistant/components/pollen/__init__.py deleted file mode 100644 index 566297ecb14..00000000000 --- a/homeassistant/components/pollen/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The pollen component.""" diff --git a/homeassistant/components/pollen/manifest.json b/homeassistant/components/pollen/manifest.json deleted file mode 100644 index 2edf83a0d1f..00000000000 --- a/homeassistant/components/pollen/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "pollen", - "name": "Pollen", - "documentation": "https://www.home-assistant.io/components/pollen", - "requirements": [ - "numpy==1.16.2", - "pypollencom==2.2.3" - ], - "dependencies": [], - "codeowners": [ - "@bachya" - ] -} diff --git a/homeassistant/components/pollen/sensor.py b/homeassistant/components/pollen/sensor.py deleted file mode 100644 index 132155c7f65..00000000000 --- a/homeassistant/components/pollen/sensor.py +++ /dev/null @@ -1,403 +0,0 @@ -"""Support for Pollen.com allergen and cold/flu sensors.""" -from datetime import timedelta -import logging -from statistics import mean - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS) -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -ATTR_ALLERGEN_AMOUNT = 'allergen_amount' -ATTR_ALLERGEN_GENUS = 'allergen_genus' -ATTR_ALLERGEN_NAME = 'allergen_name' -ATTR_ALLERGEN_TYPE = 'allergen_type' -ATTR_CITY = 'city' -ATTR_OUTLOOK = 'outlook' -ATTR_RATING = 'rating' -ATTR_SEASON = 'season' -ATTR_TREND = 'trend' -ATTR_ZIP_CODE = 'zip_code' - -CONF_ZIP_CODE = 'zip_code' - -DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' -DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) - -TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted' -TYPE_ALLERGY_HISTORIC = 'allergy_average_historical' -TYPE_ALLERGY_INDEX = 'allergy_index' -TYPE_ALLERGY_OUTLOOK = 'allergy_outlook' -TYPE_ALLERGY_TODAY = 'allergy_index_today' -TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow' -TYPE_ALLERGY_YESTERDAY = 'allergy_index_yesterday' -TYPE_ASTHMA_FORECAST = 'asthma_average_forecasted' -TYPE_ASTHMA_HISTORIC = 'asthma_average_historical' -TYPE_ASTHMA_INDEX = 'asthma_index' -TYPE_ASTHMA_TODAY = 'asthma_index_today' -TYPE_ASTHMA_TOMORROW = 'asthma_index_tomorrow' -TYPE_ASTHMA_YESTERDAY = 'asthma_index_yesterday' -TYPE_DISEASE_FORECAST = 'disease_average_forecasted' - -SENSORS = { - TYPE_ALLERGY_FORECAST: ( - 'ForecastSensor', 'Allergy Index: Forecasted Average', 'mdi:flower'), - TYPE_ALLERGY_HISTORIC: ( - 'HistoricalSensor', 'Allergy Index: Historical Average', 'mdi:flower'), - TYPE_ALLERGY_TODAY: ('IndexSensor', 'Allergy Index: Today', 'mdi:flower'), - TYPE_ALLERGY_TOMORROW: ( - 'IndexSensor', 'Allergy Index: Tomorrow', 'mdi:flower'), - TYPE_ALLERGY_YESTERDAY: ( - 'IndexSensor', 'Allergy Index: Yesterday', 'mdi:flower'), - TYPE_ASTHMA_TODAY: ('IndexSensor', 'Asthma Index: Today', 'mdi:flower'), - TYPE_ASTHMA_TOMORROW: ( - 'IndexSensor', 'Asthma Index: Tomorrow', 'mdi:flower'), - TYPE_ASTHMA_YESTERDAY: ( - 'IndexSensor', 'Asthma Index: Yesterday', 'mdi:flower'), - TYPE_ASTHMA_FORECAST: ( - 'ForecastSensor', 'Asthma Index: Forecasted Average', 'mdi:flower'), - TYPE_ASTHMA_HISTORIC: ( - 'HistoricalSensor', 'Asthma Index: Historical Average', 'mdi:flower'), - TYPE_DISEASE_FORECAST: ( - 'ForecastSensor', 'Cold & Flu: Forecasted Average', 'mdi:snowflake') -} - -RATING_MAPPING = [{ - 'label': 'Low', - 'minimum': 0.0, - 'maximum': 2.4 -}, { - 'label': 'Low/Medium', - 'minimum': 2.5, - 'maximum': 4.8 -}, { - 'label': 'Medium', - 'minimum': 4.9, - 'maximum': 7.2 -}, { - 'label': 'Medium/High', - 'minimum': 7.3, - 'maximum': 9.6 -}, { - 'label': 'High', - 'minimum': 9.7, - 'maximum': 12 -}] - -TREND_INCREASING = 'Increasing' -TREND_SUBSIDING = 'Subsiding' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ZIP_CODE): - str, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): - vol.All(cv.ensure_list, [vol.In(SENSORS)]) -}) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - from pypollencom import Client - - websession = aiohttp_client.async_get_clientsession(hass) - - pollen = PollenComData( - Client(config[CONF_ZIP_CODE], websession), - config[CONF_MONITORED_CONDITIONS]) - - await pollen.async_update() - - sensors = [] - for kind in config[CONF_MONITORED_CONDITIONS]: - sensor_class, name, icon = SENSORS[kind] - sensors.append( - globals()[sensor_class]( - pollen, kind, name, icon, config[CONF_ZIP_CODE])) - - async_add_entities(sensors, True) - - -def calculate_average_rating(indices): - """Calculate the human-friendly historical allergy average.""" - ratings = list( - r['label'] for n in indices for r in RATING_MAPPING - if r['minimum'] <= n <= r['maximum']) - return max(set(ratings), key=ratings.count) - - -def calculate_trend(indices): - """Calculate the "moving average" of a set of indices.""" - import numpy as np - - def moving_average(data, samples): - """Determine the "moving average" (http://tinyurl.com/yaereb3c).""" - ret = np.cumsum(data, dtype=float) - ret[samples:] = ret[samples:] - ret[:-samples] - return ret[samples - 1:] / samples - - increasing = np.all(np.diff(moving_average(np.array(indices), 4)) > 0) - - if increasing: - return TREND_INCREASING - return TREND_SUBSIDING - - -class BaseSensor(Entity): - """Define a base Pollen.com sensor.""" - - def __init__(self, pollen, kind, name, icon, zip_code): - """Initialize the sensor.""" - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._icon = icon - self._kind = kind - self._name = name - self._state = None - self._zip_code = zip_code - self.pollen = pollen - - @property - def available(self): - """Return True if entity is available.""" - if self._kind in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, - TYPE_ALLERGY_YESTERDAY): - return bool(self.pollen.data[TYPE_ALLERGY_INDEX]) - - if self._kind in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_ASTHMA_YESTERDAY): - return bool(self.pollen.data[TYPE_ASTHMA_INDEX]) - - return bool(self.pollen.data[self._kind]) - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}'.format(self._zip_code, self._kind) - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return 'index' - - -class ForecastSensor(BaseSensor): - """Define sensor related to forecast data.""" - - async def async_update(self): - """Update the sensor.""" - await self.pollen.async_update() - if not self.pollen.data: - return - - data = self.pollen.data[self._kind].get('Location') - if not data: - return - - indices = [p['Index'] for p in data['periods']] - average = round(mean(indices), 1) - [rating] = [ - i['label'] for i in RATING_MAPPING - if i['minimum'] <= average <= i['maximum'] - ] - - self._attrs.update({ - ATTR_CITY: data['City'].title(), - ATTR_RATING: rating, - ATTR_STATE: data['State'], - ATTR_TREND: calculate_trend(indices), - ATTR_ZIP_CODE: data['ZIP'] - }) - - if self._kind == TYPE_ALLERGY_FORECAST: - outlook = self.pollen.data[TYPE_ALLERGY_OUTLOOK] - self._attrs[ATTR_OUTLOOK] = outlook.get('Outlook') - self._attrs[ATTR_SEASON] = outlook.get('Season') - - self._state = average - - -class HistoricalSensor(BaseSensor): - """Define sensor related to historical data.""" - - async def async_update(self): - """Update the sensor.""" - await self.pollen.async_update() - if not self.pollen.data: - return - - data = self.pollen.data[self._kind].get('Location') - if not data: - return - - indices = [p['Index'] for p in data['periods']] - average = round(mean(indices), 1) - - self._attrs.update({ - ATTR_CITY: data['City'].title(), - ATTR_RATING: calculate_average_rating(indices), - ATTR_STATE: data['State'], - ATTR_TREND: calculate_trend(indices), - ATTR_ZIP_CODE: data['ZIP'] - }) - - self._state = average - - -class IndexSensor(BaseSensor): - """Define sensor related to indices.""" - - async def async_update(self): - """Update the sensor.""" - await self.pollen.async_update() - if not self.pollen.data: - return - - data = {} - if self._kind in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, - TYPE_ALLERGY_YESTERDAY): - data = self.pollen.data[TYPE_ALLERGY_INDEX].get('Location') - elif self._kind in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_ASTHMA_YESTERDAY): - data = self.pollen.data[TYPE_ASTHMA_INDEX].get('Location') - - if not data: - return - - key = self._kind.split('_')[-1].title() - [period] = [p for p in data['periods'] if p['Type'] == key] - [rating] = [ - i['label'] for i in RATING_MAPPING - if i['minimum'] <= period['Index'] <= i['maximum'] - ] - - self._attrs.update({ - ATTR_CITY: data['City'].title(), - ATTR_RATING: rating, - ATTR_STATE: data['State'], - ATTR_ZIP_CODE: data['ZIP'] - }) - - if self._kind in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, - TYPE_ALLERGY_YESTERDAY): - for idx, attrs in enumerate(period['Triggers']): - index = idx + 1 - self._attrs.update({ - '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index): - attrs['Genus'], - '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): - attrs['Name'], - '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index): - attrs['PlantType'], - }) - elif self._kind in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_ASTHMA_YESTERDAY): - for idx, attrs in enumerate(period['Triggers']): - index = idx + 1 - self._attrs.update({ - '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): - attrs['Name'], - '{0}_{1}'.format(ATTR_ALLERGEN_AMOUNT, index): - attrs['PPM'], - }) - - self._state = period['Index'] - - -class PollenComData: - """Define a data object to retrieve info from Pollen.com.""" - - def __init__(self, client, sensor_types): - """Initialize.""" - self._client = client - self._sensor_types = sensor_types - self.data = {} - - async def _get_data(self, method, key): - """Return API data from a specific call.""" - from pypollencom.errors import PollenComError - - try: - data = await method() - self.data[key] = data - except PollenComError as err: - _LOGGER.error('Unable to get "%s" data: %s', key, err) - self.data[key] = {} - - @Throttle(DEFAULT_SCAN_INTERVAL) - async def async_update(self): - """Update Pollen.com data.""" - from pypollencom.errors import InvalidZipError - - # Pollen.com requires a bit more complicated error handling, given that - # it sometimes has parts (but not the whole thing) go down: - # - # 1. If `InvalidZipError` is thrown, quit everything immediately. - # 2. If an individual request throws any other error, try the others. - - try: - if TYPE_ALLERGY_FORECAST in self._sensor_types: - await self._get_data( - self._client.allergens.extended, TYPE_ALLERGY_FORECAST) - await self._get_data( - self._client.allergens.outlook, TYPE_ALLERGY_OUTLOOK) - - if TYPE_ALLERGY_HISTORIC in self._sensor_types: - await self._get_data( - self._client.allergens.historic, TYPE_ALLERGY_HISTORIC) - - if any(s in self._sensor_types - for s in [TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, - TYPE_ALLERGY_YESTERDAY]): - await self._get_data( - self._client.allergens.current, TYPE_ALLERGY_INDEX) - - if TYPE_ASTHMA_FORECAST in self._sensor_types: - await self._get_data( - self._client.asthma.extended, TYPE_ASTHMA_FORECAST) - - if TYPE_ASTHMA_HISTORIC in self._sensor_types: - await self._get_data( - self._client.asthma.historic, TYPE_ASTHMA_HISTORIC) - - if any(s in self._sensor_types - for s in [TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, - TYPE_ASTHMA_YESTERDAY]): - await self._get_data( - self._client.asthma.current, TYPE_ASTHMA_INDEX) - - if TYPE_DISEASE_FORECAST in self._sensor_types: - await self._get_data( - self._client.disease.extended, TYPE_DISEASE_FORECAST) - - _LOGGER.debug("New data retrieved: %s", self.data) - except InvalidZipError: - _LOGGER.error( - "Cannot retrieve data for ZIP code: %s", self._client.zip_code) - self.data = {} diff --git a/homeassistant/components/ps4/.translations/ca.json b/homeassistant/components/ps4/.translations/ca.json index 5e4b572ab45..166d2674934 100644 --- a/homeassistant/components/ps4/.translations/ca.json +++ b/homeassistant/components/ps4/.translations/ca.json @@ -4,17 +4,18 @@ "credential_error": "Error en l'obtenci\u00f3 de les credencials.", "devices_configured": "Tots els dispositius trobats ja estan configurats.", "no_devices_found": "No s'han trobat dispositius PlayStation 4 a la xarxa.", - "port_987_bind_error": "No s'ha pogut vincular amb el port 987.", - "port_997_bind_error": "No s'ha pogut vincular amb el port 997." + "port_987_bind_error": "No s'ha pogut vincular amb el port 987. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3.", + "port_997_bind_error": "No s'ha pogut vincular amb el port 997. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3." }, "error": { + "credential_timeout": "El servei de credencials ha expirat. Prem Envia per reiniciar-lo.", "login_failed": "No s'ha pogut sincronitzar amb la PlayStation 4. Verifica el codi PIN.", "no_ipaddress": "Introdueix l'adre\u00e7a IP de la PlayStation 4 que vulguis configurar.", "not_ready": "La PlayStation 4 no est\u00e0 engegada o no s'ha connectada a la xarxa." }, "step": { "creds": { - "description": "Credencials necess\u00e0ries. Prem 'Enviar' i, a continuaci\u00f3, a la segona pantalla de l'aplicaci\u00f3 de la PS4, actualitza els dispositius i selecciona 'Home-Assistant' per continuar.", + "description": "Credencials necess\u00e0ries. Prem 'Envia' i, a continuaci\u00f3, a la segona pantalla de l'aplicaci\u00f3 de la PS4, actualitza els dispositius i selecciona 'Home-Assistant' per continuar.", "title": "PlayStation 4" }, "link": { @@ -24,7 +25,7 @@ "name": "Nom", "region": "Regi\u00f3" }, - "description": "Introdueix la informaci\u00f3 de la teva PlayStation 4. Pel 'PIN', ves a 'Configuraci\u00f3' de la PlayStation 4, despr\u00e9s navega fins a 'Configuraci\u00f3 de la connexi\u00f3 de l'aplicaci\u00f3 m\u00f2bil' i selecciona 'Afegir dispositiu'. Introdueix el PIN que es mostra.", + "description": "Introdueix la informaci\u00f3 de la teva PlayStation 4. Pel 'PIN', ves a 'Configuraci\u00f3' a la consola de la PlayStation 4. Despr\u00e9s navega fins a 'Configuraci\u00f3 de la connexi\u00f3 de l'aplicaci\u00f3 m\u00f2bil' i selecciona 'Afegir dispositiu'. Introdueix el PIN que es mostra. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3.", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/ps4/.translations/de.json b/homeassistant/components/ps4/.translations/de.json index e2eadb1fe30..e9ad0b59e0c 100644 --- a/homeassistant/components/ps4/.translations/de.json +++ b/homeassistant/components/ps4/.translations/de.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Bind to Port 997 nicht m\u00f6glich." }, "error": { + "credential_timeout": "Zeit\u00fcberschreitung beim Warten auf den Anmeldedienst. Klicken zum Neustarten auf Senden.", "login_failed": "Fehler beim Koppeln mit PlayStation 4. \u00dcberpr\u00fcfe, ob die PIN korrekt ist.", "no_ipaddress": "Gib die IP-Adresse der PlayStation 4 ein, die konfiguriert werden soll.", "not_ready": "PlayStation 4 ist nicht eingeschaltet oder mit dem Netzwerk verbunden." diff --git a/homeassistant/components/ps4/.translations/en.json b/homeassistant/components/ps4/.translations/en.json index 8949e77b4cc..756eb65d4f7 100644 --- a/homeassistant/components/ps4/.translations/en.json +++ b/homeassistant/components/ps4/.translations/en.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info." }, "error": { + "credential_timeout": "Credential service timed out. Press submit to restart.", "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure.", "not_ready": "PlayStation 4 is not on or connected to network." diff --git a/homeassistant/components/ps4/.translations/es.json b/homeassistant/components/ps4/.translations/es.json index a159cd0552a..fd68e06a552 100644 --- a/homeassistant/components/ps4/.translations/es.json +++ b/homeassistant/components/ps4/.translations/es.json @@ -4,8 +4,8 @@ "credential_error": "Error al obtener las credenciales.", "devices_configured": "Todos los dispositivos encontrados ya est\u00e1n configurados.", "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red.", - "port_987_bind_error": "No se pudo unir al puerto 987.", - "port_997_bind_error": "No se pudo unir al puerto 997." + "port_987_bind_error": "No se ha podido unir al puerto 987. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n.", + "port_997_bind_error": "No se ha podido unir al puerto 997. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n." }, "error": { "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.", diff --git a/homeassistant/components/ps4/.translations/hu.json b/homeassistant/components/ps4/.translations/hu.json new file mode 100644 index 00000000000..6a000895723 --- /dev/null +++ b/homeassistant/components/ps4/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "mode": { + "data": { + "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d" + }, + "title": "PlayStation 4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/ko.json b/homeassistant/components/ps4/.translations/ko.json index d42586505d9..51454eeb135 100644 --- a/homeassistant/components/ps4/.translations/ko.json +++ b/homeassistant/components/ps4/.translations/ko.json @@ -8,6 +8,7 @@ "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": { + "credential_timeout": "\uc790\uaca9 \uc99d\uba85 \uc11c\ube44\uc2a4 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4. Submit \uc744 \ub20c\ub7ec \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.", "not_ready": "PlayStation 4 \uac00 \ucf1c\uc838 \uc788\uc9c0 \uc54a\uac70\ub098 \ub124\ud2b8\uc6cc\ud06c\uc5d0 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." diff --git a/homeassistant/components/ps4/.translations/lb.json b/homeassistant/components/ps4/.translations/lb.json index 5c5847f28c0..17757cb9d20 100644 --- a/homeassistant/components/ps4/.translations/lb.json +++ b/homeassistant/components/ps4/.translations/lb.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Konnt sech net mam Port 997 verbannen." }, "error": { + "credential_timeout": "Z\u00e4it Iwwerschreidung beim Service vun den Umeldungsinformatiounen. Dr\u00e9ck op ofsch\u00e9cke fir nach emol ze starten.", "login_failed": "Feeler beim verbanne mat der Playstation 4. Iwwerpr\u00e9ift op de PIN korrekt ass.", "no_ipaddress": "Gitt d'IP Adresse vun der Playstation 4 an:", "not_ready": "PlayStation 4 ass net un oder mam Netzwierk verbonnen." diff --git a/homeassistant/components/ps4/.translations/no.json b/homeassistant/components/ps4/.translations/no.json index ea2c0b37f6e..132ec5b83ec 100644 --- a/homeassistant/components/ps4/.translations/no.json +++ b/homeassistant/components/ps4/.translations/no.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Kunne ikke binde til port 997. Se [dokumentasjonen] (https://www.home-assistant.io/components/ps4/) for videre informasjon." }, "error": { + "credential_timeout": "Legitimasjonstjenesten ble tidsavbrutt. Trykk send for \u00e5 starte p\u00e5 nytt.", "login_failed": "Klarte ikke \u00e5 koble til PlayStation 4. Bekreft at PIN koden er riktig.", "no_ipaddress": "Angi IP adressen til din PlayStation 4 som du \u00f8nsker konfigurere.", "not_ready": "PlayStation 4 er ikke p\u00e5sl\u00e5tt eller koblet til nettverk." diff --git a/homeassistant/components/ps4/.translations/pl.json b/homeassistant/components/ps4/.translations/pl.json index d38dabe3188..3e36960b12c 100644 --- a/homeassistant/components/ps4/.translations/pl.json +++ b/homeassistant/components/ps4/.translations/pl.json @@ -8,6 +8,7 @@ "port_997_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 997." }, "error": { + "credential_timeout": "Up\u0142yn\u0105\u0142 limit czasu us\u0142ugi po\u015bwiadcze\u0144. Naci\u015bnij przycisk Prze\u015blij, aby ponownie uruchomi\u0107.", "login_failed": "Nie uda\u0142o si\u0119 sparowa\u0107 z PlayStation 4. Sprawd\u017a, czy PIN jest poprawny.", "no_ipaddress": "Wprowad\u017a adres IP PlayStation 4, kt\u00f3ry chcesz skonfigurowa\u0107.", "not_ready": "PlayStation 4 nie jest w\u0142\u0105czona lub po\u0142\u0105czona z sieci\u0105." diff --git a/homeassistant/components/ps4/.translations/ru.json b/homeassistant/components/ps4/.translations/ru.json index d69213d7d75..b50d4bb838f 100644 --- a/homeassistant/components/ps4/.translations/ru.json +++ b/homeassistant/components/ps4/.translations/ru.json @@ -8,6 +8,7 @@ "port_997_bind_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u043e\u0440\u0442\u0443 997. \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](https://www.home-assistant.io/components/ps4/)." }, "error": { + "credential_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443.", "login_failed": "\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 \u0441 PlayStation 4. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e PIN-\u043a\u043e\u0434 \u0432\u0432\u0435\u0434\u0435\u043d \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e.", "no_ipaddress": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 PlayStation 4.", "not_ready": "PlayStation 4 \u043d\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u0438\u043b\u0438 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043a \u0441\u0435\u0442\u0438." diff --git a/homeassistant/components/ps4/.translations/sv.json b/homeassistant/components/ps4/.translations/sv.json index 642497b1074..81f24179e54 100644 --- a/homeassistant/components/ps4/.translations/sv.json +++ b/homeassistant/components/ps4/.translations/sv.json @@ -9,6 +9,7 @@ }, "error": { "login_failed": "Misslyckades med att para till PlayStation 4. Verifiera PIN-koden \u00e4r korrekt.", + "no_ipaddress": "Ange IP-adressen f\u00f6r PlayStation 4 du vill konfigurera.", "not_ready": "PlayStation 4 \u00e4r inte p\u00e5slagen eller ansluten till n\u00e4tverket." }, "step": { @@ -28,6 +29,7 @@ }, "mode": { "data": { + "ip_address": "IP-adress (l\u00e4mna tom om du anv\u00e4nder automatisk uppt\u00e4ckt).", "mode": "Konfigureringsl\u00e4ge" }, "title": "PlayStation 4" diff --git a/homeassistant/components/ps4/.translations/zh-Hant.json b/homeassistant/components/ps4/.translations/zh-Hant.json index 54740e2c727..a59f3e85578 100644 --- a/homeassistant/components/ps4/.translations/zh-Hant.json +++ b/homeassistant/components/ps4/.translations/zh-Hant.json @@ -8,6 +8,7 @@ "port_997_bind_error": "\u7121\u6cd5\u7d81\u5b9a\u901a\u8a0a\u57e0 997\u3002" }, "error": { + "credential_timeout": "\u6191\u8b49\u670d\u52d9\u903e\u6642\uff0c\u9ede\u9078\u300c\u50b3\u9001\u300d\u4ee5\u91cd\u555f\u3002", "login_failed": "PlayStation 4 \u914d\u5c0d\u5931\u6557\uff0c\u8acb\u78ba\u8a8d PIN \u78bc\u3002", "no_ipaddress": "\u8f38\u5165\u6240\u8981\u8a2d\u5b9a\u7684 PlayStation 4 \u4e4b IP \u4f4d\u5740\u3002", "not_ready": "PlayStation 4 \u4e26\u672a\u958b\u555f\u6216\u672a\u9023\u7dda\u81f3\u7db2\u8def\u3002" diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 22c21fcffbe..b91e6b239e7 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -1,7 +1,9 @@ """Support for PlayStation 4 consoles.""" import logging -from homeassistant.const import CONF_REGION +from homeassistant.core import split_entity_id +from homeassistant.const import CONF_REGION, CONF_TOKEN +from homeassistant.helpers import entity_registry from homeassistant.util import location from .config_flow import PlayStation4FlowHandler # noqa: pylint: disable=unused-import @@ -37,21 +39,57 @@ async def async_migrate_entry(hass, entry): data = entry.data version = entry.version - reason = {1: "Region codes have changed"} # From 0.89 + _LOGGER.debug("Migrating PS4 entry from Version %s", version) - # Migrate Version 1 -> Version 2 + reason = { + 1: "Region codes have changed", + 2: "Format for Unique ID for entity registry has changed" + } + + # Migrate Version 1 -> Version 2: New region codes. if version == 1: - loc = await hass.async_add_executor_job(location.detect_location_info) + loc = await location.async_detect_location_info( + hass.helpers.aiohttp_client.async_get_clientsession() + ) if loc: country = loc.country_name if country in COUNTRIES: for device in data['devices']: device[CONF_REGION] = country - entry.version = 2 + version = entry.version = 2 config_entries.async_update_entry(entry, data=data) _LOGGER.info( "PlayStation 4 Config Updated: \ Region changed to: %s", country) + + # Migrate Version 2 -> Version 3: Update identifier format. + if version == 2: + # Prevent changing entity_id. Updates entity registry. + registry = await entity_registry.async_get_registry(hass) + + for entity_id, e_entry in registry.entities.items(): + if e_entry.config_entry_id == entry.entry_id: + unique_id = e_entry.unique_id + + # Remove old entity entry. + registry.async_remove(entity_id) + + # Format old unique_id. + unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) + + # Create new entry with old entity_id. + new_id = split_entity_id(entity_id)[1] + registry.async_get_or_create( + 'media_player', DOMAIN, unique_id, + suggested_object_id=new_id, + config_entry_id=e_entry.config_entry_id, + device_id=e_entry.device_id + ) + entry.version = 3 + _LOGGER.info( + "PlayStation 4 identifier for entity: %s \ + has changed", entity_id) + config_entries.async_update_entry(entry) return True msg = """{} for the PlayStation 4 Integration. @@ -64,3 +102,9 @@ async def async_migrate_entry(hass, entry): notification_id='config_entry_migration' ) return False + + +def format_unique_id(creds, mac_address): + """Use last 4 Chars of credential as suffix. Unique ID per PSN user.""" + suffix = creds[-4:] + return "{}_{}".format(mac_address, suffix) diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 1b184a3774f..b31ba44fbe3 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN) +from homeassistant.util import location from .const import DEFAULT_NAME, DOMAIN @@ -25,7 +26,7 @@ PORT_MSG = {UDP_PORT: 'port_987_bind_error', TCP_PORT: 'port_997_bind_error'} class PlayStation4FlowHandler(config_entries.ConfigFlow): """Handle a PlayStation 4 config flow.""" - VERSION = 2 + VERSION = 3 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL def __init__(self): @@ -39,6 +40,7 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): self.region = None self.pin = None self.m_device = None + self.location = None self.device_list = [] async def async_step_user(self, user_input=None): @@ -50,23 +52,25 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): if failed in ports: reason = PORT_MSG[failed] return self.async_abort(reason=reason) - # Skip Creds Step if a device is configured. - if self.hass.config_entries.async_entries(DOMAIN): - return await self.async_step_mode() return await self.async_step_creds() async def async_step_creds(self, user_input=None): """Return PS4 credentials from 2nd Screen App.""" + from pyps4_homeassistant.errors import CredentialTimeout + errors = {} if user_input is not None: - self.creds = await self.hass.async_add_executor_job( - self.helper.get_creds) - - if self.creds is not None: - return await self.async_step_mode() - return self.async_abort(reason='credential_error') + try: + self.creds = await self.hass.async_add_executor_job( + self.helper.get_creds) + if self.creds is not None: + return await self.async_step_mode() + return self.async_abort(reason='credential_error') + except CredentialTimeout: + errors['base'] = 'credential_timeout' return self.async_show_form( - step_id='creds') + step_id='creds', + errors=errors) async def async_step_mode(self, user_input=None): """Prompt for mode.""" @@ -99,6 +103,7 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): """Prompt user input. Create or edit entry.""" from pyps4_homeassistant.media_art import COUNTRIES regions = sorted(COUNTRIES.keys()) + default_region = None errors = {} if user_input is None: @@ -112,26 +117,23 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): self.device_list = [device['host-ip'] for device in devices] - # If entry exists check that devices found aren't configured. - if self.hass.config_entries.async_entries(DOMAIN): - creds = {} - for entry in self.hass.config_entries.async_entries(DOMAIN): - # Retrieve creds from entry - creds['data'] = entry.data[CONF_TOKEN] - # Retrieve device data from entry - conf_devices = entry.data['devices'] - for c_device in conf_devices: - if c_device['host'] in self.device_list: - # Remove configured device from search list. - self.device_list.remove(c_device['host']) + # Check that devices found aren't configured per account. + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + # Retrieve device data from all entries if creds match. + conf_devices = [device for entry in entries + if self.creds == entry.data[CONF_TOKEN] + for device in entry.data['devices']] + + # Remove configured device from search list. + for c_device in conf_devices: + if c_device['host'] in self.device_list: + # Remove configured device from search list. + self.device_list.remove(c_device['host']) + # If list is empty then all devices are configured. if not self.device_list: return self.async_abort(reason='devices_configured') - # Add existing creds for linking. Should be only 1. - if not creds: - # Abort if creds is missing. - return self.async_abort(reason='credential_error') - self.creds = creds['data'] # Login to PS4 with user data. if user_input is not None: @@ -163,11 +165,22 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): }, ) + # Try to find region automatically. + if not self.location: + self.location = await location.async_detect_location_info( + self.hass.helpers.aiohttp_client.async_get_clientsession() + ) + if self.location: + country = self.location.country_name + if country in COUNTRIES: + default_region = country + # Show User Input form. link_schema = OrderedDict() link_schema[vol.Required(CONF_IP_ADDRESS)] = vol.In( list(self.device_list)) - link_schema[vol.Required(CONF_REGION)] = vol.In(list(regions)) + link_schema[vol.Required( + CONF_REGION, default=default_region)] = vol.In(list(regions)) link_schema[vol.Required(CONF_CODE)] = vol.All( vol.Strip, vol.Length(min=8, max=8), vol.Coerce(int)) link_schema[vol.Required(CONF_NAME, default=DEFAULT_NAME)] = str diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 605dd3f530c..087f1618378 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -3,8 +3,10 @@ "name": "Ps4", "documentation": "https://www.home-assistant.io/components/ps4", "requirements": [ - "pyps4-homeassistant==0.5.2" + "pyps4-homeassistant==0.7.3" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@ktnrg45" + ] } diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 3382cd6fe43..f5360f491db 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -7,8 +7,9 @@ import voluptuous as vol from homeassistant.components.media_player import ( ENTITY_IMAGE_URL, MediaPlayerDevice) from homeassistant.components.media_player.const import ( - MEDIA_TYPE_GAME, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON) + MEDIA_TYPE_GAME, MEDIA_TYPE_APP, SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) +from homeassistant.components.ps4 import format_unique_id from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_REGION, CONF_TOKEN, STATE_IDLE, STATE_OFF, STATE_PLAYING) @@ -87,7 +88,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = device[CONF_NAME] ps4 = pyps4.Ps4(host, creds) device_list.append(PS4Device( - name, host, region, ps4, games_file)) + name, host, region, ps4, creds, games_file)) add_entities(device_list, True) @@ -102,21 +103,24 @@ class PS4Data(): class PS4Device(MediaPlayerDevice): """Representation of a PS4.""" - def __init__(self, name, host, region, ps4, games_file): + def __init__(self, name, host, region, ps4, creds, games_file): """Initialize the ps4 device.""" self._ps4 = ps4 self._host = host self._name = name self._region = region + self._creds = creds self._state = None self._games_filename = games_file self._media_content_id = None self._media_title = None self._media_image = None + self._media_type = None self._source = None self._games = {} self._source_list = [] self._retry = 0 + self._disconnected = False self._info = None self._unique_id = None self._power_on = False @@ -145,6 +149,7 @@ class PS4Device(MediaPlayerDevice): status = None if status is not None: self._retry = 0 + self._disconnected = False if status.get('status') == 'Ok': # Check if only 1 device in Hass. if len(self.hass.data[PS4_DATA].devices) == 1: @@ -187,7 +192,9 @@ class PS4Device(MediaPlayerDevice): """Set states for state unknown.""" self.reset_title() self._state = None - _LOGGER.warning("PS4 could not be reached") + if self._disconnected is False: + _LOGGER.warning("PS4 could not be reached") + self._disconnected = True self._retry = 0 def reset_title(self): @@ -198,19 +205,27 @@ class PS4Device(MediaPlayerDevice): def get_title_data(self, title_id, name): """Get PS Store Data.""" + from pyps4_homeassistant.errors import PSDataIncomplete app_name = None art = None try: - app_name, art = self._ps4.get_ps_store_data( + title = self._ps4.get_ps_store_data( name, title_id, self._region) - except TypeError: + except PSDataIncomplete: _LOGGER.error( "Could not find data in region: %s for PS ID: %s", self._region, title_id) + else: + app_name = title.name + art = title.cover_art finally: self._media_title = app_name or name self._source = self._media_title self._media_image = art + if title.game_type == 'App': + self._media_type = MEDIA_TYPE_APP + else: + self._media_type = MEDIA_TYPE_GAME self.update_list() def update_list(self): @@ -257,7 +272,7 @@ class PS4Device(MediaPlayerDevice): self.save_games(games) def get_device_info(self, status): - """Return device info for registry.""" + """Set device info for registry.""" _sw_version = status['system-version'] _sw_version = _sw_version[1:4] sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:]) @@ -270,12 +285,14 @@ class PS4Device(MediaPlayerDevice): 'manufacturer': 'Sony Interactive Entertainment Inc.', 'sw_version': sw_version } - self._unique_id = status['host-id'] + + self._unique_id = format_unique_id(self._creds, status['host-id']) async def async_will_remove_from_hass(self): """Remove Entity from Hass.""" # Close TCP Socket - await self.hass.async_add_executor_job(self._ps4.close) + if self._ps4.connected: + await self.hass.async_add_executor_job(self._ps4.close) self.hass.data[PS4_DATA].devices.remove(self) @property @@ -321,7 +338,7 @@ class PS4Device(MediaPlayerDevice): @property def media_content_type(self): """Content type of current playing media.""" - return MEDIA_TYPE_GAME + return self._media_type @property def media_image_url(self): @@ -370,13 +387,18 @@ class PS4Device(MediaPlayerDevice): def select_source(self, source): """Select input source.""" for title_id, game in self._games.items(): - if source == game: + if source.lower().encode(encoding='utf-8') == \ + game.lower().encode(encoding='utf-8') \ + or source == title_id: _LOGGER.debug( "Starting PS4 game %s (%s) using source %s", game, title_id, source) self._ps4.start_title( title_id, running_id=self._media_content_id) return + _LOGGER.warning( + "Could not start title. '%s' is not in source list", source) + return def send_command(self, command): """Send Button Command.""" diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index ea69d8c7a8c..77443b1ee9a 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -26,6 +26,7 @@ } }, "error": { + "credential_timeout": "Credential service timed out. Press submit to restart.", "not_ready": "PlayStation 4 is not on or connected to network.", "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure." diff --git a/homeassistant/components/ptvsd/__init__.py b/homeassistant/components/ptvsd/__init__.py new file mode 100644 index 00000000000..2a86e15ddd2 --- /dev/null +++ b/homeassistant/components/ptvsd/__init__.py @@ -0,0 +1,63 @@ +""" +Enable ptvsd debugger to attach to HA. + +Attach ptvsd debugger by default to port 5678. +""" + +import logging +from threading import Thread +from asyncio import Event + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +DOMAIN = 'ptvsd' + +CONF_WAIT = 'wait' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional( + CONF_HOST, default='0.0.0.0' + ): cv.string, + vol.Optional( + CONF_PORT, default=5678 + ): cv.port, + vol.Optional( + CONF_WAIT, default=False + ): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up ptvsd debugger.""" + import ptvsd + + conf = config[DOMAIN] + host = conf[CONF_HOST] + port = conf[CONF_PORT] + + ptvsd.enable_attach((host, port)) + + wait = conf[CONF_WAIT] + if wait: + _LOGGER.warning("Waiting for ptvsd connection on %s:%s", host, port) + ready = Event() + + def waitfor(): + ptvsd.wait_for_attach() + hass.loop.call_soon_threadsafe(ready.set) + Thread(target=waitfor).start() + + await ready.wait() + else: + _LOGGER.warning("Listening for ptvsd connection on %s:%s", host, port) + + return True diff --git a/homeassistant/components/ptvsd/manifest.json b/homeassistant/components/ptvsd/manifest.json new file mode 100644 index 00000000000..8bd46c3dc32 --- /dev/null +++ b/homeassistant/components/ptvsd/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ptvsd", + "name": "ptvsd", + "documentation": "https://www.home-assistant.io/components/ptvsd", + "requirements": [ + "ptvsd==4.2.8" + ], + "dependencies": [], + "codeowners": ["@swamp-ig"] +} diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 8c058557fc1..672f1be4694 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.service import verify_domain_control from .config_flow import configured_instances from .const import ( DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, - OPERATION_RESTRICTIONS_CURRENT, OPERATION_RESTRICTIONS_UNIVERSAL) + PROVISION_SETTINGS, RESTRICTIONS_CURRENT, RESTRICTIONS_UNIVERSAL) _LOGGER = logging.getLogger(__name__) @@ -40,6 +40,11 @@ DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' DEFAULT_ICON = 'mdi:water' DEFAULT_ZONE_RUN = 60 * 10 +TYPE_FLOW_SENSOR = 'flow_sensor' +TYPE_FLOW_SENSOR_CLICK_M3 = 'flow_sensor_clicks_cubic_meter' +TYPE_FLOW_SENSOR_CONSUMED_LITERS = 'flow_sensor_consumed_liters' +TYPE_FLOW_SENSOR_START_INDEX = 'flow_sensor_start_index' +TYPE_FLOW_SENSOR_WATERING_CLICKS = 'flow_sensor_watering_clicks' TYPE_FREEZE = 'freeze' TYPE_FREEZE_PROTECTION = 'freeze_protection' TYPE_FREEZE_TEMP = 'freeze_protect_temp' @@ -51,6 +56,7 @@ TYPE_RAINSENSOR = 'rainsensor' TYPE_WEEKDAY = 'weekday' BINARY_SENSORS = { + TYPE_FLOW_SENSOR: ('Flow Sensor', 'mdi:water-pump'), TYPE_FREEZE: ('Freeze Restrictions', 'mdi:cancel'), TYPE_FREEZE_PROTECTION: ('Freeze Protection', 'mdi:weather-snowy'), TYPE_HOT_DAYS: ('Extra Water on Hot Days', 'mdi:thermometer-lines'), @@ -62,6 +68,14 @@ BINARY_SENSORS = { } SENSORS = { + TYPE_FLOW_SENSOR_CLICK_M3: ( + 'Flow Sensor Clicks', 'mdi:water-pump', 'clicks/m^3'), + TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( + 'Flow Sensor Consumed Liters', 'mdi:water-pump', 'liter'), + TYPE_FLOW_SENSOR_START_INDEX: ( + 'Flow Sensor Start Index', 'mdi:water-pump', None), + TYPE_FLOW_SENSOR_WATERING_CLICKS: ( + 'Flow Sensor Clicks', 'mdi:water-pump', 'clicks'), TYPE_FREEZE_TEMP: ('Freeze Protect Temperature', 'mdi:thermometer', '°C'), } @@ -319,11 +333,26 @@ class RainMachine: """Update sensor/binary sensor data.""" from regenmaschine.errors import RainMachineError - tasks = { - OPERATION_RESTRICTIONS_CURRENT: self.client.restrictions.current(), - OPERATION_RESTRICTIONS_UNIVERSAL: - self.client.restrictions.universal(), - } + tasks = {} + + if (TYPE_FLOW_SENSOR in self.binary_sensor_conditions + or any(c in self.sensor_conditions + for c in (TYPE_FLOW_SENSOR_CLICK_M3, + TYPE_FLOW_SENSOR_CONSUMED_LITERS, + TYPE_FLOW_SENSOR_START_INDEX, + TYPE_FLOW_SENSOR_WATERING_CLICKS))): + tasks[PROVISION_SETTINGS] = self.client.provisioning.settings() + + if any(c in self.binary_sensor_conditions + for c in (TYPE_FREEZE, TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, + TYPE_RAINSENSOR, TYPE_WEEKDAY)): + tasks[RESTRICTIONS_CURRENT] = self.client.restrictions.current() + + if (any(c in self.binary_sensor_conditions + for c in (TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS)) + or TYPE_FREEZE_TEMP in self.sensor_conditions): + tasks[RESTRICTIONS_UNIVERSAL] = ( + self.client.restrictions.universal()) results = await asyncio.gather(*tasks.values(), return_exceptions=True) for operation, result in zip(tasks, results): diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 57dbcb551ed..3d818a4e5ce 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -7,10 +7,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( BINARY_SENSORS, DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, - OPERATION_RESTRICTIONS_CURRENT, OPERATION_RESTRICTIONS_UNIVERSAL, - SENSOR_UPDATE_TOPIC, TYPE_FREEZE, TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, - TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, - RainMachineEntity) + PROVISION_SETTINGS, RESTRICTIONS_CURRENT, RESTRICTIONS_UNIVERSAL, + SENSOR_UPDATE_TOPIC, TYPE_FLOW_SENSOR, TYPE_FREEZE, TYPE_FREEZE_PROTECTION, + TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, + TYPE_WEEKDAY, RainMachineEntity) _LOGGER = logging.getLogger(__name__) @@ -79,27 +79,27 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): async def async_update(self): """Update the state.""" - if self._sensor_type == TYPE_FREEZE: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['freeze'] + if self._sensor_type == TYPE_FLOW_SENSOR: + self._state = self.rainmachine.data[PROVISION_SETTINGS].get( + 'useFlowSensor') + elif self._sensor_type == TYPE_FREEZE: + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['freeze'] elif self._sensor_type == TYPE_FREEZE_PROTECTION: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_UNIVERSAL]['freezeProtectEnabled'] + self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + 'freezeProtectEnabled'] elif self._sensor_type == TYPE_HOT_DAYS: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_UNIVERSAL]['hotDaysExtraWatering'] + self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + 'hotDaysExtraWatering'] elif self._sensor_type == TYPE_HOURLY: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['hourly'] + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['hourly'] elif self._sensor_type == TYPE_MONTH: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['month'] + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['month'] elif self._sensor_type == TYPE_RAINDELAY: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['rainDelay'] + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][ + 'rainDelay'] elif self._sensor_type == TYPE_RAINSENSOR: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['rainSensor'] + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][ + 'rainSensor'] elif self._sensor_type == TYPE_WEEKDAY: - self._state = self.rainmachine.data[ - OPERATION_RESTRICTIONS_CURRENT]['weekDay'] + self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][ + 'weekDay'] diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index d142467443f..c6e001ab981 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) DOMAIN = 'rainmachine' @@ -12,7 +12,8 @@ DEFAULT_PORT = 8080 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_SSL = True -OPERATION_RESTRICTIONS_CURRENT = 'restrictions.current' -OPERATION_RESTRICTIONS_UNIVERSAL = 'restrictions.universal' +PROVISION_SETTINGS = 'provision.settings' +RESTRICTIONS_CURRENT = 'restrictions.current' +RESTRICTIONS_UNIVERSAL = 'restrictions.universal' TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 4894bd2ce39..5b7052959d8 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -5,9 +5,11 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( - DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, - OPERATION_RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, SENSORS, - RainMachineEntity) + DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROVISION_SETTINGS, + RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, SENSORS, + TYPE_FLOW_SENSOR_CLICK_M3, TYPE_FLOW_SENSOR_CONSUMED_LITERS, + TYPE_FLOW_SENSOR_START_INDEX, TYPE_FLOW_SENSOR_WATERING_CLICKS, + TYPE_FREEZE_TEMP, RainMachineEntity) _LOGGER = logging.getLogger(__name__) @@ -82,5 +84,25 @@ class RainMachineSensor(RainMachineEntity): async def async_update(self): """Update the sensor's state.""" - self._state = self.rainmachine.data[OPERATION_RESTRICTIONS_UNIVERSAL][ - 'freezeProtectTemp'] + if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3: + self._state = self.rainmachine.data[PROVISION_SETTINGS].get( + 'flowSensorClicksPerCubicMeter') + elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: + clicks = self.rainmachine.data[PROVISION_SETTINGS].get( + 'flowSensorWateringClicks') + clicks_per_m3 = self.rainmachine.data[PROVISION_SETTINGS].get( + 'flowSensorClicksPerCubicMeter') + + if clicks and clicks_per_m3: + self._state = (clicks * 1000) / clicks_per_m3 + else: + self._state = None + elif self._sensor_type == TYPE_FLOW_SENSOR_START_INDEX: + self._state = self.rainmachine.data[PROVISION_SETTINGS].get( + 'flowSensorStartIndex') + elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: + self._state = self.rainmachine.data[PROVISION_SETTINGS].get( + 'flowSensorWateringClicks') + elif self._sensor_type == TYPE_FREEZE_TEMP: + self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + 'freezeProtectTemp'] diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index c466d35e23f..32fc227444a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -3,7 +3,7 @@ "name": "Recorder", "documentation": "https://www.home-assistant.io/components/recorder", "requirements": [ - "sqlalchemy==1.3.0" + "sqlalchemy==1.3.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index 8134e73ae6b..50b9436ba00 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -11,8 +11,8 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, - BaseNotificationService) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_MESSAGE, + PLATFORM_SCHEMA, BaseNotificationService) CONF_DATA = 'data' CONF_DATA_TEMPLATE = 'data_template' @@ -110,6 +110,8 @@ class RestNotificationService(BaseNotificationService): if self._data: data.update(self._data) elif self._data_template: + kwargs[ATTR_MESSAGE] = message + def _data_template_creator(value): """Recursive template creator helper function.""" if isinstance(value, list): @@ -119,6 +121,7 @@ class RestNotificationService(BaseNotificationService): for key, item in value.items()} value.hass = self._hass return value.async_render(kwargs) + data.update(_data_template_creator(self._data_template)) if self._method == 'POST': diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index 47372d861f1..eb006f408bc 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -3,7 +3,7 @@ "name": "Sendgrid", "documentation": "https://www.home-assistant.io/components/sendgrid", "requirements": [ - "sendgrid==5.6.0" + "sendgrid==6.0.5" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 563e47b8afe..c5f00c0bb71 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -42,7 +42,7 @@ class SendgridNotificationService(BaseNotificationService): self.sender_name = config[CONF_SENDER_NAME] self.recipient = config[CONF_RECIPIENT] - self._sg = SendGridAPIClient(apikey=self.api_key) + self._sg = SendGridAPIClient(self.api_key) def send_message(self, message='', **kwargs): """Send an email to a user via SendGrid.""" diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 49e61bead05..a6762c4be35 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -3,15 +3,15 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_NAME -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_SERIAL_DEVICE = 'serial_device' CONF_BRAND = 'brand' +CONF_SERIAL_DEVICE = 'serial_device' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_BRAND): cv.string, @@ -82,7 +82,3 @@ class ParticulateMatterSensor(Entity): self._state = self._collector.read_data()[self._pmname] except KeyError: _LOGGER.error("Could not read PM%s value", self._pmname) - - def should_poll(self): - """Sensor needs polling.""" - return True diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index 3882d8796c7..295d2a44f5b 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -5,15 +5,15 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import LockDevice, PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, + ATTR_BATTERY_LEVEL, CONF_API_KEY, STATE_LOCKED, STATE_UNLOCKED) from homeassistant.helpers.typing import ConfigType ATTR_DEVICE_ID = 'device_id' +ATTR_SERIAL_NO = 'serial' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_EMAIL): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_API_KEY): cv.string }) @@ -21,13 +21,12 @@ def setup_platform( hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None): """Set up the Sesame platform.""" - import pysesame + import pysesame2 - email = config.get(CONF_EMAIL) - password = config.get(CONF_PASSWORD) + api_key = config.get(CONF_API_KEY) add_entities([SesameDevice(sesame) for sesame in - pysesame.get_sesames(email, password)], + pysesame2.get_sesames(api_key)], update_before_add=True) @@ -40,9 +39,10 @@ class SesameDevice(LockDevice): # Cached properties from pysesame object. self._device_id = None + self._serial = None self._nickname = None - self._is_unlocked = False - self._api_enabled = False + self._is_locked = False + self._responsive = False self._battery = -1 @property @@ -53,19 +53,17 @@ class SesameDevice(LockDevice): @property def available(self) -> bool: """Return True if entity is available.""" - return self._api_enabled + return self._responsive @property def is_locked(self) -> bool: """Return True if the device is currently locked, else False.""" - return not self._is_unlocked + return self._is_locked @property def state(self) -> str: """Get the state of the device.""" - if self._is_unlocked: - return STATE_UNLOCKED - return STATE_LOCKED + return STATE_LOCKED if self._is_locked else STATE_UNLOCKED def lock(self, **kwargs) -> None: """Lock the device.""" @@ -77,17 +75,19 @@ class SesameDevice(LockDevice): def update(self) -> None: """Update the internal state of the device.""" - self._sesame.update_state() + status = self._sesame.get_status() self._nickname = self._sesame.nickname - self._api_enabled = self._sesame.api_enabled - self._is_unlocked = self._sesame.is_unlocked - self._device_id = self._sesame.device_id - self._battery = self._sesame.battery + self._device_id = str(self._sesame.id) + self._serial = self._sesame.serial + self._battery = status['battery'] + self._is_locked = status['locked'] + self._responsive = status['responsive'] @property def device_state_attributes(self) -> dict: """Return the state attributes.""" attributes = {} attributes[ATTR_DEVICE_ID] = self._device_id + attributes[ATTR_SERIAL_NO] = self._serial attributes[ATTR_BATTERY_LEVEL] = self._battery return attributes diff --git a/homeassistant/components/sesame/manifest.json b/homeassistant/components/sesame/manifest.json index 9aed47462fe..ad6c71bd19f 100644 --- a/homeassistant/components/sesame/manifest.json +++ b/homeassistant/components/sesame/manifest.json @@ -1,9 +1,9 @@ { "domain": "sesame", - "name": "Sesame", + "name": "Sesame Smart Lock", "documentation": "https://www.home-assistant.io/components/sesame", "requirements": [ - "pysesame==0.1.0" + "pysesame2==1.0.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index f9bae50698b..b8df1bbaaf1 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -33,6 +33,8 @@ DATA_SUMMARY = 'summary_data' DEFAULT_ATTRIBUTION = 'Data provided by 17track.net' DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) +ENTITY_ID_TEMPLATE = 'package_{0}_{1}' + NOTIFICATION_DELIVERED_ID_SCAFFOLD = 'package_delivered_{0}' NOTIFICATION_DELIVERED_TITLE = 'Package Delivered' NOTIFICATION_DELIVERED_URL_SCAFFOLD = 'https://t.17track.net/track#nums={0}' @@ -71,8 +73,8 @@ async def async_setup_platform( scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) data = SeventeenTrackData( - client, async_add_entities, scan_interval, config[CONF_SHOW_ARCHIVED], - config[CONF_SHOW_DELIVERED]) + hass, client, async_add_entities, scan_interval, + config[CONF_SHOW_ARCHIVED], config[CONF_SHOW_DELIVERED]) await data.async_update() sensors = [] @@ -208,7 +210,7 @@ class SeventeenTrackPackageSensor(Entity): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return 'package_{0}_{1}'.format( + return ENTITY_ID_TEMPLATE.format( self._data.account_id, self._tracking_number) async def async_update(self): @@ -227,12 +229,13 @@ class SeventeenTrackPackageSensor(Entity): # delete this entity: _LOGGER.info( 'Deleting entity for stale package: %s', self._tracking_number) + reg = await self.hass.helpers.entity_registry.async_get_registry() + self.hass.async_create_task(reg.async_remove(self.entity_id)) self.hass.async_create_task(self.async_remove()) return # If the user has elected to not see delivered packages and one gets - # delivered, post a notification, remove the entity from the UI, and - # delete it from the entity registry: + # delivered, post a notification: if package.status == VALUE_DELIVERED and not self._data.show_delivered: _LOGGER.info('Package delivered: %s', self._tracking_number) self.hass.components.persistent_notification.create( @@ -245,10 +248,6 @@ class SeventeenTrackPackageSensor(Entity): title=NOTIFICATION_DELIVERED_TITLE, notification_id=NOTIFICATION_DELIVERED_ID_SCAFFOLD.format( self._tracking_number)) - - reg = self.hass.helpers.entity_registry.async_get_registry() - self.hass.async_create_task(reg.async_remove(self.entity_id)) - self.hass.async_create_task(self.async_remove()) return self._attrs.update({ @@ -262,11 +261,12 @@ class SeventeenTrackData: """Define a data handler for 17track.net.""" def __init__( - self, client, async_add_entities, scan_interval, show_archived, - show_delivered): + self, hass, client, async_add_entities, scan_interval, + show_archived, show_delivered): """Initialize.""" self._async_add_entities = async_add_entities self._client = client + self._hass = hass self._scan_interval = scan_interval self._show_archived = show_archived self.account_id = client.profile.account_id @@ -296,6 +296,18 @@ class SeventeenTrackData: for package in to_add ], True) + # Remove archived packages from the entity registry: + to_remove = set(self.packages) - set(packages) + reg = await self._hass.helpers.entity_registry.async_get_registry() + for package in to_remove: + entity_id = reg.async_get_entity_id( + 'sensor', 'seventeentrack', + ENTITY_ID_TEMPLATE.format( + self.account_id, package.tracking_number)) + if not entity_id: + continue + self._hass.async_create_task(reg.async_remove(entity_id)) + self.packages = packages except SeventeenTrackError as err: _LOGGER.error('There was an error retrieving packages: %s', err) diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json index 8898b7976b5..eeb5f1c5309 100644 --- a/homeassistant/components/shodan/manifest.json +++ b/homeassistant/components/shodan/manifest.json @@ -3,7 +3,7 @@ "name": "Shodan", "documentation": "https://www.home-assistant.io/components/shodan", "requirements": [ - "shodan==1.11.1" + "shodan==1.13.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json index 12d0f63356f..802a2e6b842 100644 --- a/homeassistant/components/simplisafe/.translations/es.json +++ b/homeassistant/components/simplisafe/.translations/es.json @@ -11,7 +11,7 @@ "password": "Contrase\u00f1a", "username": "Direcci\u00f3n de correo electr\u00f3nico" }, - "title": "Rellene sus datos" + "title": "Completa tus datos" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 69311778698..2cbe5632b6b 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -119,8 +119,6 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel): """Update alarm status.""" from simplipy.system import SystemStates - await self._system.update() - self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off if self._system.temperature: self._attrs[ATTR_TEMPERATURE] = self._system.temperature diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 3b6e764f814..38fa5fa0894 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -3,7 +3,7 @@ "name": "Slack", "documentation": "https://www.home-assistant.io/components/slack", "requirements": [ - "slacker==0.12.0" + "slacker==0.13.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/smartthings/.translations/ca.json b/homeassistant/components/smartthings/.translations/ca.json index 1f27e781ee3..e1fca79c24e 100644 --- a/homeassistant/components/smartthings/.translations/ca.json +++ b/homeassistant/components/smartthings/.translations/ca.json @@ -8,18 +8,18 @@ "token_forbidden": "El testimoni d'autenticaci\u00f3 no t\u00e9 cont\u00e9 els apartats OAuth obligatoris.", "token_invalid_format": "El testimoni d'autenticaci\u00f3 ha d'estar en format UID/GUID", "token_unauthorized": "El testimoni d'autenticaci\u00f3 no \u00e9s v\u00e0lid o ja no t\u00e9 autoritzaci\u00f3.", - "webhook_error": "SmartThings no ha pogut validar l'adre\u00e7a final configurada a `base_url`. Revisa els [requisits del component]({component_url})." + "webhook_error": "SmartThings no ha pogut validar l'adre\u00e7a final configurada a `base_url`. Revisa els requisits del component." }, "step": { "user": { "data": { "access_token": "Testimoni d'acc\u00e9s" }, - "description": "Introdueix un [testimoni d'autenticaci\u00f3 d'acc\u00e9s personal] ({token_url}) de SmartThings que s'ha creat a trav\u00e9s les [instruccions] ({component_url}).", - "title": "Introdueix el testimoni d'autenticaci\u00f3 d'acc\u00e9s personal" + "description": "Introdueix un [testimoni d'acc\u00e9s personal]({token_url}) de SmartThings que s'ha creat a trav\u00e9s les [instruccions]({component_url}).", + "title": "Introdueix el testimoni d'autenticaci\u00f3 personal" }, "wait_install": { - "description": "Instal\u00b7la l'SmartApp de Home Assistant en almenys una ubicaci\u00f3 i fes clic a Enviar.", + "description": "Instal\u00b7la l'SmartApp de Home Assistant en almenys una ubicaci\u00f3 i prem a Envia.", "title": "Instal\u00b7laci\u00f3 de SmartApp" } }, diff --git a/homeassistant/components/smartthings/.translations/cs.json b/homeassistant/components/smartthings/.translations/cs.json new file mode 100644 index 00000000000..f0d55c50b26 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "app_not_installed": "Zkontrolujte, zda jste nainstalovali a autorizovali aplikaci Home Assistant SmartApp a zkuste to znovu.", + "app_setup_error": "Nelze nastavit SmartApp. Pros\u00edm zkuste to znovu.", + "base_url_not_https": "' Base_url ' pro komponentu ' http ' mus\u00ed b\u00fdt nakonfigurov\u00e1na a za\u010d\u00ednat ' https://'.", + "token_already_setup": "Token ji\u017e byl nastaven.", + "token_forbidden": "Token nem\u00e1 po\u017eadovan\u00e9 rozsahy OAuth.", + "token_invalid_format": "Token mus\u00ed b\u00fdt ve form\u00e1tu UID/GUID.", + "token_unauthorized": "Token je neplatn\u00fd nebo ji\u017e nen\u00ed autorizov\u00e1n.", + "webhook_error": "SmartThings nemohly ov\u011b\u0159it koncov\u00fd bod nakonfigurovan\u00fd v `base_url`. P\u0159e\u010dt\u011bte si pros\u00edm po\u017eadavky na komponenty." + }, + "step": { + "user": { + "data": { + "access_token": "P\u0159\u00edstupov\u00fd token" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/es.json b/homeassistant/components/smartthings/.translations/es.json index 5534b4e3bb3..9ae98bcb9f1 100644 --- a/homeassistant/components/smartthings/.translations/es.json +++ b/homeassistant/components/smartthings/.translations/es.json @@ -1,7 +1,7 @@ { "config": { "error": { - "app_not_installed": "Por favor aseg\u00farese de haber instalado y autorizado Home Assistant SmartApp y vuelva a intentarlo.", + "app_not_installed": "Aseg\u00farate de haber instalado y autorizado la SmartApp de Home Assistant y vuelve a intentarlo.", "app_setup_error": "No se pudo configurar el SmartApp. Por favor, int\u00e9ntelo de nuevo.", "base_url_not_https": "La 'base_url' del componente 'http' debe empezar por 'https://'.", "token_already_setup": "El token ya ha sido configurado.", diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f45ea10ce47..f872e14bc77 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -39,19 +39,19 @@ AC_MODE_TO_STATE = { 'auto': STATE_AUTO, 'cool': STATE_COOL, 'dry': STATE_DRY, + 'coolClean': STATE_COOL, + 'dryClean': STATE_DRY, 'heat': STATE_HEAT, + 'heatClean': STATE_HEAT, 'fanOnly': STATE_FAN_ONLY } -STATE_TO_AC_MODE = {v: k for k, v in AC_MODE_TO_STATE.items()} - -SPEED_TO_FAN_MODE = { - 0: 'auto', - 1: 'low', - 2: 'medium', - 3: 'high', - 4: 'turbo' +STATE_TO_AC_MODE = { + STATE_AUTO: 'auto', + STATE_COOL: 'cool', + STATE_DRY: 'dry', + STATE_HEAT: 'heat', + STATE_FAN_ONLY: 'fanOnly' } -FAN_MODE_TO_SPEED = {v: k for k, v in SPEED_TO_FAN_MODE.items()} UNIT_MAP = { 'C': TEMP_CELSIUS, @@ -73,7 +73,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ac_capabilities = [ Capability.air_conditioner_mode, - Capability.fan_speed, + Capability.air_conditioner_fan_mode, Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint] @@ -98,7 +98,7 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: supported = [ Capability.air_conditioner_mode, Capability.demand_response_load_control, - Capability.fan_speed, + Capability.air_conditioner_fan_mode, Capability.power_consumption_report, Capability.relative_humidity_measurement, Capability.switch, @@ -124,7 +124,7 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: # Or must have all of these A/C capabilities ac_capabilities = [ Capability.air_conditioner_mode, - Capability.fan_speed, + Capability.air_conditioner_fan_mode, Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint] @@ -309,10 +309,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateDevice): class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): """Define a SmartThings Air Conditioner.""" + def __init__(self, device): + """Init the class.""" + super().__init__(device) + self._operations = None + async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" - await self._device.set_fan_speed( - FAN_MODE_TO_SPEED[fan_mode], set_status=True) + await self._device.set_fan_mode(fan_mode, 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_schedule_update_ha_state() @@ -354,10 +358,23 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): # the entity state ahead of receiving the confirming push updates self.async_schedule_update_ha_state() + async def async_update(self): + """Update the calculated fields of the AC.""" + operations = set() + for mode in self._device.status.supported_ac_modes: + state = AC_MODE_TO_STATE.get(mode) + if state is not None: + operations.add(state) + else: + _LOGGER.debug('Device %s (%s) returned an invalid supported ' + 'AC mode: %s', self._device.label, + self._device.device_id, mode) + self._operations = operations + @property def current_fan_mode(self): """Return the fan setting.""" - return SPEED_TO_FAN_MODE.get(self._device.status.fan_speed) + return self._device.status.fan_mode @property def current_operation(self): @@ -397,7 +414,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): @property def fan_list(self): """Return the list of available fan modes.""" - return list(FAN_MODE_TO_SPEED) + return self._device.status.supported_ac_fan_modes @property def is_on(self): @@ -407,7 +424,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): @property def operation_list(self): """Return the list of available operation modes.""" - return list(STATE_TO_AC_MODE) + return self._operations @property def supported_features(self): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index d4baf69b108..d31a90c6eb8 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/components/smartthings", "requirements": [ "pysmartapp==0.3.2", - "pysmartthings==0.6.7" + "pysmartthings==0.6.8" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 4cd1d6f7b4b..d04f867e204 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -13,4 +13,4 @@ ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( HOME_LOCATION_NAME) -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/snmp/const.py b/homeassistant/components/snmp/const.py new file mode 100644 index 00000000000..e0d73cadc10 --- /dev/null +++ b/homeassistant/components/snmp/const.py @@ -0,0 +1,43 @@ +"""SNMP constants.""" +CONF_ACCEPT_ERRORS = 'accept_errors' +CONF_AUTH_KEY = 'auth_key' +CONF_AUTH_PROTOCOL = 'auth_protocol' +CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' +CONF_DEFAULT_VALUE = 'default_value' +CONF_PRIV_KEY = 'priv_key' +CONF_PRIV_PROTOCOL = 'priv_protocol' +CONF_VERSION = 'version' + +DEFAULT_AUTH_PROTOCOL = 'none' +DEFAULT_COMMUNITY = 'public' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'SNMP' +DEFAULT_PORT = '161' +DEFAULT_PRIV_PROTOCOL = 'none' +DEFAULT_VERSION = '1' + +SNMP_VERSIONS = { + '1': 0, + '2c': 1, + '3': None +} + +MAP_AUTH_PROTOCOLS = { + 'none': 'usmNoAuthProtocol', + 'hmac-md5': 'usmHMACMD5AuthProtocol', + 'hmac-sha': 'usmHMACSHAAuthProtocol', + 'hmac128-sha224': 'usmHMAC128SHA224AuthProtocol', + 'hmac192-sha256': 'usmHMAC192SHA256AuthProtocol', + 'hmac256-sha384': 'usmHMAC256SHA384AuthProtocol', + 'hmac384-sha512': 'usmHMAC384SHA512AuthProtocol', +} + +MAP_PRIV_PROTOCOLS = { + 'none': 'usmNoPrivProtocol', + 'des': 'usmDESPrivProtocol', + '3des-ede': 'usm3DESEDEPrivProtocol', + 'aes-cfb-128': 'usmAesCfb128Protocol', + 'aes-cfb-192': 'usmAesCfb192Protocol', + 'aes-cfb-256': 'usmAesCfb256Protocol', +} diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index b36681161cb..7d3eb9f577b 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -4,26 +4,23 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_AUTH_KEY, CONF_BASEOID, CONF_COMMUNITY, CONF_PRIV_KEY, + DEFAULT_COMMUNITY) _LOGGER = logging.getLogger(__name__) -CONF_AUTHKEY = 'authkey' -CONF_BASEOID = 'baseoid' -CONF_COMMUNITY = 'community' -CONF_PRIVKEY = 'privkey' - -DEFAULT_COMMUNITY = 'public' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_BASEOID): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, - vol.Inclusive(CONF_AUTHKEY, 'keys'): cv.string, - vol.Inclusive(CONF_PRIVKEY, 'keys'): cv.string, + vol.Inclusive(CONF_AUTH_KEY, 'keys'): cv.string, + vol.Inclusive(CONF_PRIV_KEY, 'keys'): cv.string, }) @@ -44,13 +41,13 @@ class SnmpScanner(DeviceScanner): self.snmp = cmdgen.CommandGenerator() self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161)) - if CONF_AUTHKEY not in config or CONF_PRIVKEY not in config: + if CONF_AUTH_KEY not in config or CONF_PRIV_KEY not in config: self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY]) else: self.auth = cmdgen.UsmUserData( config[CONF_COMMUNITY], - config[CONF_AUTHKEY], - config[CONF_PRIVKEY], + config[CONF_AUTH_KEY], + config[CONF_PRIV_KEY], authProtocol=cfg.usmHMACSHAAuthProtocol, privProtocol=cfg.usmAesCfb128Protocol ) @@ -108,7 +105,7 @@ class SnmpScanner(DeviceScanner): mac = binascii.hexlify(val.asOctets()).decode('utf-8') except AttributeError: continue - _LOGGER.debug("Found MAC %s", mac) + _LOGGER.debug("Found MAC address: %s", mac) mac = ':'.join([mac[i:i+2] for i in range(0, len(mac), 2)]) devices.append({'mac': mac}) return devices diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index aeaa3451683..0007a5a66e5 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -3,7 +3,7 @@ "name": "Snmp", "documentation": "https://www.home-assistant.io/components/snmp", "requirements": [ - "pysnmp==4.4.8" + "pysnmp==4.4.9" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index df132140c38..b2b57baf9e4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -1,61 +1,25 @@ """Support for displaying collected data over SNMP.""" -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, - CONF_USERNAME, CONF_VALUE_TEMPLATE) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from .const import ( + CONF_ACCEPT_ERRORS, CONF_AUTH_KEY, CONF_AUTH_PROTOCOL, CONF_BASEOID, + CONF_COMMUNITY, CONF_DEFAULT_VALUE, CONF_PRIV_KEY, CONF_PRIV_PROTOCOL, + CONF_VERSION, DEFAULT_AUTH_PROTOCOL, DEFAULT_COMMUNITY, DEFAULT_HOST, + DEFAULT_NAME, DEFAULT_PORT, DEFAULT_PRIV_PROTOCOL, DEFAULT_VERSION, + MAP_AUTH_PROTOCOLS, MAP_PRIV_PROTOCOLS, SNMP_VERSIONS) _LOGGER = logging.getLogger(__name__) -CONF_BASEOID = 'baseoid' -CONF_COMMUNITY = 'community' -CONF_VERSION = 'version' -CONF_AUTH_KEY = 'auth_key' -CONF_AUTH_PROTOCOL = 'auth_protocol' -CONF_PRIV_KEY = 'priv_key' -CONF_PRIV_PROTOCOL = 'priv_protocol' -CONF_ACCEPT_ERRORS = 'accept_errors' -CONF_DEFAULT_VALUE = 'default_value' - -DEFAULT_COMMUNITY = 'public' -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'SNMP' -DEFAULT_PORT = '161' -DEFAULT_VERSION = '1' -DEFAULT_AUTH_PROTOCOL = 'none' -DEFAULT_PRIV_PROTOCOL = 'none' - -SNMP_VERSIONS = { - '1': 0, - '2c': 1, - '3': None -} - -MAP_AUTH_PROTOCOLS = { - 'none': 'usmNoAuthProtocol', - 'hmac-md5': 'usmHMACMD5AuthProtocol', - 'hmac-sha': 'usmHMACSHAAuthProtocol', - 'hmac128-sha224': 'usmHMAC128SHA224AuthProtocol', - 'hmac192-sha256': 'usmHMAC192SHA256AuthProtocol', - 'hmac256-sha384': 'usmHMAC256SHA384AuthProtocol', - 'hmac384-sha512': 'usmHMAC384SHA512AuthProtocol', -} - -MAP_PRIV_PROTOCOLS = { - 'none': 'usmNoPrivProtocol', - 'des': 'usmDESPrivProtocol', - '3des-ede': 'usm3DESEDEPrivProtocol', - 'aes-cfb-128': 'usmAesCfb128Protocol', - 'aes-cfb-192': 'usmAesCfb192Protocol', - 'aes-cfb-256': 'usmAesCfb256Protocol', -} - SCAN_INTERVAL = timedelta(seconds=10) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -194,7 +158,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - from pysnmp.hlapi.asyncio import (getCmd, ObjectType, ObjectIdentity) + from pysnmp.hlapi.asyncio import getCmd, ObjectType, ObjectIdentity errindication, errstatus, errindex, restable = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid))) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 5555f511272..0ad387b2da4 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -3,59 +3,27 @@ import logging import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, + CONF_HOST, CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_PORT, CONF_USERNAME) import homeassistant.helpers.config_validation as cv +from .const import ( + CONF_AUTH_KEY, CONF_AUTH_PROTOCOL, CONF_BASEOID, CONF_COMMUNITY, + CONF_PRIV_KEY, CONF_PRIV_PROTOCOL, CONF_VERSION, DEFAULT_AUTH_PROTOCOL, + DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DEFAULT_PRIV_PROTOCOL, + DEFAULT_VERSION, MAP_AUTH_PROTOCOLS, MAP_PRIV_PROTOCOLS, SNMP_VERSIONS) + _LOGGER = logging.getLogger(__name__) -CONF_BASEOID = 'baseoid' CONF_COMMAND_OID = 'command_oid' -CONF_COMMAND_PAYLOAD_ON = 'command_payload_on' CONF_COMMAND_PAYLOAD_OFF = 'command_payload_off' -CONF_COMMUNITY = 'community' -CONF_VERSION = 'version' -CONF_AUTH_KEY = 'auth_key' -CONF_AUTH_PROTOCOL = 'auth_protocol' -CONF_PRIV_KEY = 'priv_key' -CONF_PRIV_PROTOCOL = 'priv_protocol' +CONF_COMMAND_PAYLOAD_ON = 'command_payload_on' -DEFAULT_NAME = 'SNMP Switch' -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = '161' DEFAULT_COMMUNITY = 'private' -DEFAULT_VERSION = '1' -DEFAULT_AUTH_PROTOCOL = 'none' -DEFAULT_PRIV_PROTOCOL = 'none' -DEFAULT_PAYLOAD_ON = 1 DEFAULT_PAYLOAD_OFF = 0 - -SNMP_VERSIONS = { - '1': 0, - '2c': 1, - '3': None -} - -MAP_AUTH_PROTOCOLS = { - 'none': 'usmNoAuthProtocol', - 'hmac-md5': 'usmHMACMD5AuthProtocol', - 'hmac-sha': 'usmHMACSHAAuthProtocol', - 'hmac128-sha224': 'usmHMAC128SHA224AuthProtocol', - 'hmac192-sha256': 'usmHMAC192SHA256AuthProtocol', - 'hmac256-sha384': 'usmHMAC256SHA384AuthProtocol', - 'hmac384-sha512': 'usmHMAC384SHA512AuthProtocol', -} - -MAP_PRIV_PROTOCOLS = { - 'none': 'usmNoPrivProtocol', - 'des': 'usmDESPrivProtocol', - '3des-ede': 'usm3DESEDEPrivProtocol', - 'aes-cfb-128': 'usmAesCfb128Protocol', - 'aes-cfb-192': 'usmAesCfb192Protocol', - 'aes-cfb-256': 'usmAesCfb256Protocol', -} +DEFAULT_PAYLOAD_ON = 1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_BASEOID): cv.string, @@ -115,8 +83,8 @@ class SnmpSwitch(SwitchDevice): command_payload_off): """Initialize the switch.""" from pysnmp.hlapi.asyncio import ( - CommunityData, ContextData, SnmpEngine, - UdpTransportTarget, UsmUserData) + CommunityData, ContextData, SnmpEngine, UdpTransportTarget, + UsmUserData) self._name = name self._baseoid = baseoid @@ -172,8 +140,8 @@ class SnmpSwitch(SwitchDevice): async def async_update(self): """Update the state.""" - from pysnmp.hlapi.asyncio import (getCmd, ObjectType, ObjectIdentity) - from pyasn1.type.univ import (Integer) + from pysnmp.hlapi.asyncio import getCmd, ObjectType, ObjectIdentity + from pyasn1.type.univ import Integer errindication, errstatus, errindex, restable = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid))) @@ -203,7 +171,7 @@ class SnmpSwitch(SwitchDevice): return self._state async def _set(self, value): - from pysnmp.hlapi.asyncio import (setCmd, ObjectType, ObjectIdentity) + from pysnmp.hlapi.asyncio import setCmd, ObjectType, ObjectIdentity await setCmd( *self._request_args, diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index b661fa26fe7..5f7b2d04431 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -1,28 +1,145 @@ """Support to embed Sonos.""" -from homeassistant import config_entries -from homeassistant.helpers import config_entry_flow +import asyncio +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import CONF_HOSTS, ATTR_ENTITY_ID, ATTR_TIME +from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send DOMAIN = 'sonos' +CONF_ADVERTISE_ADDR = 'advertise_addr' +CONF_INTERFACE_ADDR = 'interface_addr' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + MP_DOMAIN: vol.Schema({ + vol.Optional(CONF_ADVERTISE_ADDR): cv.string, + vol.Optional(CONF_INTERFACE_ADDR): cv.string, + vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list_csv, [cv.string]), + }), + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_JOIN = 'join' +SERVICE_UNJOIN = 'unjoin' +SERVICE_SNAPSHOT = 'snapshot' +SERVICE_RESTORE = 'restore' +SERVICE_SET_TIMER = 'set_sleep_timer' +SERVICE_CLEAR_TIMER = 'clear_sleep_timer' +SERVICE_UPDATE_ALARM = 'update_alarm' +SERVICE_SET_OPTION = 'set_option' + +ATTR_SLEEP_TIME = 'sleep_time' +ATTR_ALARM_ID = 'alarm_id' +ATTR_VOLUME = 'volume' +ATTR_ENABLED = 'enabled' +ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' +ATTR_MASTER = 'master' +ATTR_WITH_GROUP = 'with_group' +ATTR_NIGHT_SOUND = 'night_sound' +ATTR_SPEECH_ENHANCE = 'speech_enhance' + +SONOS_JOIN_SCHEMA = vol.Schema({ + vol.Required(ATTR_MASTER): cv.entity_id, + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +SONOS_UNJOIN_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +SONOS_STATES_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean, +}) + +SONOS_SET_TIMER_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_SLEEP_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0, max=86399)) +}) + +SONOS_CLEAR_TIMER_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +SONOS_UPDATE_ALARM_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_ALARM_ID): cv.positive_int, + vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, + vol.Optional(ATTR_ENABLED): cv.boolean, + vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, +}) + +SONOS_SET_OPTION_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + +DATA_SERVICE_EVENT = 'sonos_service_idle' + async def async_setup(hass, config): """Set up the Sonos component.""" conf = config.get(DOMAIN) hass.data[DOMAIN] = conf or {} + hass.data[DATA_SERVICE_EVENT] = asyncio.Event() if conf is not None: hass.async_create_task(hass.config_entries.flow.async_init( DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + async def service_handle(service): + """Dispatch a service call.""" + hass.data[DATA_SERVICE_EVENT].clear() + async_dispatcher_send(hass, DOMAIN, service.service, service.data) + await hass.data[DATA_SERVICE_EVENT].wait() + + hass.services.async_register( + DOMAIN, SERVICE_JOIN, service_handle, + schema=SONOS_JOIN_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_UNJOIN, service_handle, + schema=SONOS_UNJOIN_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SNAPSHOT, service_handle, + schema=SONOS_STATES_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_RESTORE, service_handle, + schema=SONOS_STATES_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SET_TIMER, service_handle, + schema=SONOS_SET_TIMER_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_CLEAR_TIMER, service_handle, + schema=SONOS_CLEAR_TIMER_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_UPDATE_ALARM, service_handle, + schema=SONOS_UPDATE_ALARM_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + schema=SONOS_SET_OPTION_SCHEMA) + return True async def async_setup_entry(hass, entry): """Set up Sonos from a config entry.""" hass.async_create_task(hass.config_entries.async_forward_entry_setup( - entry, 'media_player')) + entry, MP_DOMAIN)) return True diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b2598bc5be9..5eac580313e 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.10" + "pysonos==0.0.12" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 7c2e5fec843..5d1cd138260 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -4,33 +4,40 @@ import datetime import functools as ft import logging import socket +import time import urllib import async_timeout -import requests -import voluptuous as vol +import pysonos +import pysonos.snapshot +from pysonos.exceptions import SoCoUPnPException, SoCoException -from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerDevice) +from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( - ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, + ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TIME, CONF_HOSTS, STATE_IDLE, STATE_OFF, STATE_PAUSED, - STATE_PLAYING) -import homeassistant.helpers.config_validation as cv + ENTITY_MATCH_ALL, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import utcnow -from . import DOMAIN as SONOS_DOMAIN - -DEPENDENCIES = ('sonos',) +from . import ( + CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR, + DATA_SERVICE_EVENT, DOMAIN as SONOS_DOMAIN, + ATTR_ALARM_ID, ATTR_ENABLED, ATTR_INCLUDE_LINKED_ZONES, ATTR_MASTER, + ATTR_NIGHT_SOUND, ATTR_SLEEP_TIME, ATTR_SPEECH_ENHANCE, ATTR_TIME, + ATTR_VOLUME, ATTR_WITH_GROUP, + SERVICE_CLEAR_TIMER, SERVICE_JOIN, SERVICE_RESTORE, SERVICE_SET_OPTION, + SERVICE_SET_TIMER, SERVICE_SNAPSHOT, SERVICE_UNJOIN, SERVICE_UPDATE_ALARM) _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +DISCOVERY_INTERVAL = 60 + # Quiet down pysonos logging to just actual problems. logging.getLogger('pysonos').setLevel(logging.WARNING) logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR) @@ -40,225 +47,118 @@ SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\ SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST -SERVICE_JOIN = 'sonos_join' -SERVICE_UNJOIN = 'sonos_unjoin' -SERVICE_SNAPSHOT = 'sonos_snapshot' -SERVICE_RESTORE = 'sonos_restore' -SERVICE_SET_TIMER = 'sonos_set_sleep_timer' -SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' -SERVICE_UPDATE_ALARM = 'sonos_update_alarm' -SERVICE_SET_OPTION = 'sonos_set_option' - DATA_SONOS = 'sonos_media_player' SOURCE_LINEIN = 'Line-in' SOURCE_TV = 'TV' -CONF_ADVERTISE_ADDR = 'advertise_addr' -CONF_INTERFACE_ADDR = 'interface_addr' - -# Service call validation schemas -ATTR_SLEEP_TIME = 'sleep_time' -ATTR_ALARM_ID = 'alarm_id' -ATTR_VOLUME = 'volume' -ATTR_ENABLED = 'enabled' -ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' -ATTR_MASTER = 'master' -ATTR_WITH_GROUP = 'with_group' -ATTR_NIGHT_SOUND = 'night_sound' -ATTR_SPEECH_ENHANCE = 'speech_enhance' - ATTR_SONOS_GROUP = 'sonos_group' UPNP_ERRORS_TO_IGNORE = ['701', '711', '712'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ADVERTISE_ADDR): cv.string, - vol.Optional(CONF_INTERFACE_ADDR): cv.string, - vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string]), -}) - -SONOS_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - -SONOS_JOIN_SCHEMA = SONOS_SCHEMA.extend({ - vol.Required(ATTR_MASTER): cv.entity_id, -}) - -SONOS_STATES_SCHEMA = SONOS_SCHEMA.extend({ - vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean, -}) - -SONOS_SET_TIMER_SCHEMA = SONOS_SCHEMA.extend({ - vol.Required(ATTR_SLEEP_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0, max=86399)) -}) - -SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ - vol.Required(ATTR_ALARM_ID): cv.positive_int, - vol.Optional(ATTR_TIME): cv.time, - vol.Optional(ATTR_VOLUME): cv.small_float, - vol.Optional(ATTR_ENABLED): cv.boolean, - vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, -}) - -SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ - vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, - vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, -}) - class SonosData: """Storage class for platform global data.""" def __init__(self, hass): """Initialize the data.""" - self.uids = set() self.entities = [] self.topology_condition = asyncio.Condition(loop=hass.loop) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Sonos platform. - - Deprecated. - """ - _LOGGER.warning('Loading Sonos via platform config is deprecated.') - _setup_platform(hass, config, add_entities, discovery_info) +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): + """Set up the Sonos platform. Obsolete.""" + _LOGGER.error( + 'Loading Sonos by media_player platform config is no longer supported') async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Sonos from a config entry.""" - def add_entities(entities, update_before_add=False): - """Sync version of async add entities.""" - hass.add_job(async_add_entities, entities, update_before_add) - - hass.async_add_executor_job( - _setup_platform, hass, hass.data[SONOS_DOMAIN].get('media_player', {}), - add_entities, None) - - -def _setup_platform(hass, config, add_entities, discovery_info): - """Set up the Sonos platform.""" - import pysonos - if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData(hass) + config = hass.data[SONOS_DOMAIN].get('media_player', {}) + advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: pysonos.config.EVENT_ADVERTISE_IP = advertise_addr - players = [] - if discovery_info: - player = pysonos.SoCo(discovery_info.get('host')) - - # If host already exists by config - if player.uid in hass.data[DATA_SONOS].uids: - return - - # If invisible, such as a stereo slave - if not player.is_visible: - return - - players.append(player) - else: + def _discovery(now=None): + """Discover players from network or configuration.""" hosts = config.get(CONF_HOSTS) + + def _discovered_player(soco): + """Handle a (re)discovered player.""" + try: + # Make sure that the player is available + _ = soco.volume + + entity = _get_entity_from_soco_uid(hass, soco.uid) + if not entity: + hass.add_job(async_add_entities, [SonosEntity(soco)]) + else: + entity.seen() + except SoCoException: + pass + if hosts: - # Support retro compatibility with comma separated list of hosts - # from config - hosts = hosts[0] if len(hosts) == 1 else hosts - hosts = hosts.split(',') if isinstance(hosts, str) else hosts for host in hosts: try: - players.append(pysonos.SoCo(socket.gethostbyname(host))) - except OSError: - _LOGGER.warning("Failed to initialize '%s'", host) + player = pysonos.SoCo(socket.gethostbyname(host)) + if player.is_visible: + _discovered_player(player) + except (OSError, SoCoException): + if now is None: + _LOGGER.warning("Failed to initialize '%s'", host) else: - players = pysonos.discover( - interface_addr=config.get(CONF_INTERFACE_ADDR), - all_households=True) + pysonos.discover_thread( + _discovered_player, + interface_addr=config.get(CONF_INTERFACE_ADDR)) - if not players: - _LOGGER.warning("No Sonos speakers found") - return + hass.helpers.event.call_later(DISCOVERY_INTERVAL, _discovery) - hass.data[DATA_SONOS].uids.update(p.uid for p in players) - add_entities(SonosEntity(p) for p in players) - _LOGGER.debug("Added %s Sonos speakers", len(players)) - - def _service_to_entities(service): - """Extract and return entities from service call.""" - entity_ids = service.data.get('entity_id') + hass.async_add_executor_job(_discovery) + async def async_service_handle(service, data): + """Handle dispatched services.""" + entity_ids = data.get('entity_id') entities = hass.data[DATA_SONOS].entities - if entity_ids: + if entity_ids and entity_ids != ENTITY_MATCH_ALL: entities = [e for e in entities if e.entity_id in entity_ids] - return entities - - async def async_service_handle(service): - """Handle async services.""" - entities = _service_to_entities(service) - - if service.service == SERVICE_JOIN: + if service == SERVICE_JOIN: master = [e for e in hass.data[DATA_SONOS].entities - if e.entity_id == service.data[ATTR_MASTER]] + if e.entity_id == data[ATTR_MASTER]] if master: await SonosEntity.join_multi(hass, master[0], entities) - elif service.service == SERVICE_UNJOIN: + elif service == SERVICE_UNJOIN: await SonosEntity.unjoin_multi(hass, entities) - elif service.service == SERVICE_SNAPSHOT: + elif service == SERVICE_SNAPSHOT: await SonosEntity.snapshot_multi( - hass, entities, service.data[ATTR_WITH_GROUP]) - elif service.service == SERVICE_RESTORE: + hass, entities, data[ATTR_WITH_GROUP]) + elif service == SERVICE_RESTORE: await SonosEntity.restore_multi( - hass, entities, service.data[ATTR_WITH_GROUP]) + hass, entities, data[ATTR_WITH_GROUP]) + else: + for entity in entities: + if service == SERVICE_SET_TIMER: + call = entity.set_sleep_timer + elif service == SERVICE_CLEAR_TIMER: + call = entity.clear_sleep_timer + elif service == SERVICE_UPDATE_ALARM: + call = entity.set_alarm + elif service == SERVICE_SET_OPTION: + call = entity.set_option - hass.services.register( - DOMAIN, SERVICE_JOIN, async_service_handle, - schema=SONOS_JOIN_SCHEMA) + hass.async_add_executor_job(call, data) - hass.services.register( - DOMAIN, SERVICE_UNJOIN, async_service_handle, - schema=SONOS_SCHEMA) + # We are ready for the next service call + hass.data[DATA_SERVICE_EVENT].set() - hass.services.register( - DOMAIN, SERVICE_SNAPSHOT, async_service_handle, - schema=SONOS_STATES_SCHEMA) - - hass.services.register( - DOMAIN, SERVICE_RESTORE, async_service_handle, - schema=SONOS_STATES_SCHEMA) - - def service_handle(service): - """Handle sync services.""" - for entity in _service_to_entities(service): - if service.service == SERVICE_SET_TIMER: - entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) - elif service.service == SERVICE_CLEAR_TIMER: - entity.clear_sleep_timer() - elif service.service == SERVICE_UPDATE_ALARM: - entity.set_alarm(**service.data) - elif service.service == SERVICE_SET_OPTION: - entity.set_option(**service.data) - - hass.services.register( - DOMAIN, SERVICE_SET_TIMER, service_handle, - schema=SONOS_SET_TIMER_SCHEMA) - - hass.services.register( - DOMAIN, SERVICE_CLEAR_TIMER, service_handle, - schema=SONOS_SCHEMA) - - hass.services.register( - DOMAIN, SERVICE_UPDATE_ALARM, service_handle, - schema=SONOS_UPDATE_ALARM_SCHEMA) - - hass.services.register( - DOMAIN, SERVICE_SET_OPTION, service_handle, - schema=SONOS_SET_OPTION_SCHEMA) + async_dispatcher_connect(hass, SONOS_DOMAIN, async_service_handle) class _ProcessSonosEventQueue: @@ -288,8 +188,6 @@ def soco_error(errorcodes=None): @ft.wraps(funct) def wrapper(*args, **kwargs): """Wrap for all soco UPnP exception.""" - from pysonos.exceptions import SoCoUPnPException, SoCoException - try: return funct(*args, **kwargs) except SoCoUPnPException as err: @@ -338,6 +236,7 @@ class SonosEntity(MediaPlayerDevice): def __init__(self, player): """Initialize the Sonos entity.""" + self._seen = None self._subscriptions = [] self._receives_events = False self._volume_increment = 2 @@ -367,6 +266,7 @@ class SonosEntity(MediaPlayerDevice): self._snapshot_group = None self._set_basic_information() + self.seen() async def async_added_to_hass(self): """Subscribe sonos events.""" @@ -426,20 +326,18 @@ class SonosEntity(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + def seen(self): + """Record that this player was seen right now.""" + self._seen = time.monotonic() + @property def available(self) -> bool: """Return True if entity is available.""" return self._available def _check_available(self): - """Check that we can still connect to the player.""" - try: - sock = socket.create_connection( - address=(self.soco.ip_address, 1443), timeout=3) - sock.close() - return True - except socket.error: - return False + """Check that we saw the player recently.""" + return self._seen > time.monotonic() - 2*DISCOVERY_INTERVAL def _set_basic_information(self): """Set initial entity information.""" @@ -500,7 +398,7 @@ class SonosEntity(MediaPlayerDevice): self._subscribe_to_player_events() else: for subscription in self._subscriptions: - self.hass.async_add_executor_job(subscription.unsubscribe) + subscription.unsubscribe() self._subscriptions = [] self._player_volume = None @@ -516,10 +414,13 @@ class SonosEntity(MediaPlayerDevice): self._media_title = None self._source_name = None elif available and not self._receives_events: - self.update_groups() - self.update_volume() - if self.is_coordinator: - self.update_media() + try: + self.update_groups() + self.update_volume() + if self.is_coordinator: + self.update_media() + except SoCoException: + pass def update_media(self, event=None): """Update information about currently playing media.""" @@ -602,7 +503,6 @@ class SonosEntity(MediaPlayerDevice): current_uri_metadata = media_info["CurrentURIMetaData"] if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): # currently soco does not have an API for this - import pysonos current_uri_metadata = pysonos.xml.XML.fromstring( pysonos.utils.really_utf8(current_uri_metadata)) @@ -706,7 +606,7 @@ class SonosEntity(MediaPlayerDevice): coordinator_uid = self.soco.group.coordinator.uid slave_uids = [p.uid for p in self.soco.group.members if p.uid != coordinator_uid] - except requests.exceptions.RequestException: + except SoCoException: pass return [coordinator_uid] + slave_uids @@ -961,7 +861,6 @@ class SonosEntity(MediaPlayerDevice): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ if kwargs.get(ATTR_MEDIA_ENQUEUE): - from pysonos.exceptions import SoCoUPnPException try: self.soco.add_uri_to_queue(media_id) except SoCoUPnPException: @@ -1022,9 +921,7 @@ class SonosEntity(MediaPlayerDevice): @soco_error() def snapshot(self, with_group): """Snapshot the state of a player.""" - from pysonos.snapshot import Snapshot - - self._soco_snapshot = Snapshot(self.soco) + self._soco_snapshot = pysonos.snapshot.Snapshot(self.soco) self._soco_snapshot.snapshot() if with_group: self._snapshot_group = self._sonos_group.copy() @@ -1053,8 +950,6 @@ class SonosEntity(MediaPlayerDevice): @soco_error() def restore(self): """Restore a snapshotted state to a player.""" - from pysonos.exceptions import SoCoException - try: # pylint: disable=protected-access self._soco_snapshot.restore() @@ -1147,19 +1042,19 @@ class SonosEntity(MediaPlayerDevice): @soco_error() @soco_coordinator - def set_sleep_timer(self, sleep_time): + def set_sleep_timer(self, data): """Set the timer on the player.""" - self.soco.set_sleep_timer(sleep_time) + self.soco.set_sleep_timer(data[ATTR_SLEEP_TIME]) @soco_error() @soco_coordinator - def clear_sleep_timer(self): + def clear_sleep_timer(self, data): """Clear the timer on the player.""" self.soco.set_sleep_timer(None) @soco_error() @soco_coordinator - def set_alarm(self, **data): + def set_alarm(self, data): """Set the alarm clock on the player.""" from pysonos import alarms alarm = None @@ -1182,7 +1077,7 @@ class SonosEntity(MediaPlayerDevice): alarm.save() @soco_error() - def set_option(self, **data): + def set_option(self, data): """Modify playback options.""" if ATTR_NIGHT_SOUND in data and self._night_sound is not None: self.soco.night_mode = data[ATTR_NIGHT_SOUND] diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index e69de29bb2d..98f53ff8d37 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -0,0 +1,67 @@ +join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the coordinator of the group. + example: 'media_player.living_room_sonos' + entity_id: + description: Name(s) of entities that will join the master. + example: 'media_player.living_room_sonos' + +unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. + example: 'media_player.living_room_sonos' + +snapshot: + description: Take a snapshot of the media player. + fields: + entity_id: + description: Name(s) of entities that will be snapshot. + example: 'media_player.living_room_sonos' + with_group: + description: True (default) or False. Also snapshot the group layout. + example: 'true' + +restore: + description: Restore a snapshot of the media player. + fields: + entity_id: + description: Name(s) of entities that will be restored. + example: 'media_player.living_room_sonos' + with_group: + description: True (default) or False. Also restore the group layout. + example: 'true' + +set_sleep_timer: + description: Set a Sonos timer. + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: 'media_player.living_room_sonos' + sleep_time: + description: Number of seconds to set the timer. + example: '900' + +clear_sleep_timer: + description: Clear a Sonos timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.living_room_sonos' + +set_option: + description: Set Sonos sound options. + fields: + entity_id: + description: Name(s) of entities that will have options set. + example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' + diff --git a/homeassistant/components/spotcrime/manifest.json b/homeassistant/components/spotcrime/manifest.json index 49b8742c53e..5827f307ecf 100644 --- a/homeassistant/components/spotcrime/manifest.json +++ b/homeassistant/components/spotcrime/manifest.json @@ -3,7 +3,7 @@ "name": "Spotcrime", "documentation": "https://www.home-assistant.io/components/spotcrime", "requirements": [ - "spotcrime==1.0.3" + "spotcrime==1.0.4" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 9a26e676018..551b1880917 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -3,7 +3,7 @@ "name": "Sql", "documentation": "https://www.home-assistant.io/components/sql", "requirements": [ - "sqlalchemy==1.3.0" + "sqlalchemy==1.3.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json index 1d13936f592..d2f9e90c41a 100644 --- a/homeassistant/components/startca/manifest.json +++ b/homeassistant/components/startca/manifest.json @@ -3,7 +3,7 @@ "name": "Startca", "documentation": "https://www.home-assistant.io/components/startca", "requirements": [ - "xmltodict==0.11.0" + "xmltodict==0.12.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 1de1fc35ea1..0e764ecb7a7 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -53,6 +53,7 @@ def request_stream(hass, stream_source, *, fmt='hls', if isinstance(stream_source, str) \ and stream_source[:7] == 'rtsp://' and not options: options['rtsp_flags'] = 'prefer_tcp' + options['stimeout'] = '5000000' try: streams = hass.data[DOMAIN][ATTR_STREAMS] diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 92cdcb0a2e4..90718fd540e 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -120,15 +120,20 @@ class Sun(Entity): """Run when the state of the sun has changed.""" self.update_sun_position(now) self.update_as_of(now) - self.async_schedule_update_ha_state() + self.async_write_ha_state() + _LOGGER.debug("sun point_in_time_listener@%s: %s, %s", + now, self.state, self.state_attributes) # Schedule next update at next_change+1 second so sun state has changed async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.next_change + timedelta(seconds=1)) + _LOGGER.debug("next time: %s", self.next_change + timedelta(seconds=1)) @callback def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.async_schedule_update_ha_state() + self.async_write_ha_state() + _LOGGER.debug("sun timer_update@%s: %s, %s", + time, self.state, self.state_attributes) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 0143855db37..b9ea4eb276e 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -3,7 +3,7 @@ "name": "Switchbot", "documentation": "https://www.home-assistant.io/components/switchbot", "requirements": [ - "PySwitchbot==0.5" + "PySwitchbot==0.6.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index b8a2a905dcb..c29dfea6737 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -46,7 +46,7 @@ class SwitchBot(SwitchDevice, RestoreEntity): state = await self.async_get_last_state() if not state: return - self._state = state.state + self._state = state.state == 'on' def turn_on(self, **kwargs) -> None: """Turn device on.""" diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py new file mode 100644 index 00000000000..43ca0abc2a0 --- /dev/null +++ b/homeassistant/components/switcher_kis/__init__.py @@ -0,0 +1,93 @@ +"""Home Assistant Switcher Component.""" + +from asyncio import QueueEmpty, TimeoutError as Asyncio_TimeoutError, wait_for +from datetime import datetime, timedelta +from logging import getLogger +from typing import Dict, Optional + +import voluptuous as vol + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import EventType, HomeAssistantType + +_LOGGER = getLogger(__name__) + +DOMAIN = 'switcher_kis' + +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_PASSWORD = 'device_password' +CONF_PHONE_ID = 'phone_id' + +DATA_DEVICE = 'device' + +SIGNAL_SWITCHER_DEVICE_UPDATE = 'switcher_device_update' + +ATTR_AUTO_OFF_SET = 'auto_off_set' +ATTR_ELECTRIC_CURRENT = 'electric_current' +ATTR_REMAINING_TIME = 'remaining_time' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PHONE_ID): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_DEVICE_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: + """Set up the switcher component.""" + from aioswitcher.bridge import SwitcherV2Bridge + + phone_id = config[DOMAIN][CONF_PHONE_ID] + device_id = config[DOMAIN][CONF_DEVICE_ID] + device_password = config[DOMAIN][CONF_DEVICE_PASSWORD] + + v2bridge = SwitcherV2Bridge( + hass.loop, phone_id, device_id, device_password) + + await v2bridge.start() + + async def async_stop_bridge(event: EventType) -> None: + """On homeassistant stop, gracefully stop the bridge if running.""" + await v2bridge.stop() + + hass.async_add_job(hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) + + try: + device_data = await wait_for( + v2bridge.queue.get(), timeout=5.0, loop=hass.loop) + except (Asyncio_TimeoutError, RuntimeError): + _LOGGER.exception("failed to get response from device") + await v2bridge.stop() + return False + + hass.data[DOMAIN] = { + DATA_DEVICE: device_data + } + + hass.async_create_task(async_load_platform( + hass, SWITCH_DOMAIN, DOMAIN, None, config)) + + @callback + def device_updates(timestamp: Optional[datetime]) -> None: + """Use for updating the device data from the queue.""" + if v2bridge.running: + try: + device_new_data = v2bridge.queue.get_nowait() + if device_new_data: + async_dispatcher_send( + hass, SIGNAL_SWITCHER_DEVICE_UPDATE, device_new_data) + except QueueEmpty: + pass + + async_track_time_interval(hass, device_updates, timedelta(seconds=4)) + + return True diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json new file mode 100644 index 00000000000..140caf51936 --- /dev/null +++ b/homeassistant/components/switcher_kis/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "switcher_kis", + "name": "Switcher", + "documentation": "https://www.home-assistant.io/components/switcher_kis/", + "codeowners": [ + "@tomerfi" + ], + "requirements": [ + "aioswitcher==2019.3.21" + ], + "dependencies": [] +} diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py new file mode 100644 index 00000000000..c66c6b52e0c --- /dev/null +++ b/homeassistant/components/switcher_kis/switch.py @@ -0,0 +1,142 @@ +"""Home Assistant Switcher Component Switch platform.""" + +from logging import getLogger +from typing import Callable, Dict, TYPE_CHECKING + +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, SwitchDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + ATTR_AUTO_OFF_SET, ATTR_ELECTRIC_CURRENT, ATTR_REMAINING_TIME, + DATA_DEVICE, DOMAIN, SIGNAL_SWITCHER_DEVICE_UPDATE) + +if TYPE_CHECKING: + from aioswitcher.devices import SwitcherV2Device + from aioswitcher.api.messages import SwitcherV2ControlResponseMSG + + +_LOGGER = getLogger(__name__) + +DEVICE_PROPERTIES_TO_HA_ATTRIBUTES = { + 'power_consumption': ATTR_CURRENT_POWER_W, + 'electric_current': ATTR_ELECTRIC_CURRENT, + 'remaining_time': ATTR_REMAINING_TIME, + 'auto_off_set': ATTR_AUTO_OFF_SET +} + + +async def async_setup_platform(hass: HomeAssistantType, config: Dict, + async_add_entities: Callable, + discovery_info: Dict) -> None: + """Set up the switcher platform for the switch component.""" + assert DOMAIN in hass.data + async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) + + +class SwitcherControl(SwitchDevice): + """Home Assistant switch entity.""" + + def __init__(self, device_data: 'SwitcherV2Device') -> None: + """Initialize the entity.""" + self._self_initiated = False + self._device_data = device_data + self._state = device_data.state + + @property + def name(self) -> str: + """Return the device's name.""" + return self._device_data.name + + @property + def should_poll(self) -> bool: + """Return False, entity pushes its state to HA.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}-{}".format( + self._device_data.device_id, self._device_data.mac_addr) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + from aioswitcher.consts import STATE_ON as SWITCHER_STATE_ON + return self._state == SWITCHER_STATE_ON + + @property + def current_power_w(self) -> int: + """Return the current power usage in W.""" + return self._device_data.power_consumption + + @property + def device_state_attributes(self) -> Dict: + """Return the optional state attributes.""" + from aioswitcher.consts import WAITING_TEXT + + attribs = {} + + for prop, attr in DEVICE_PROPERTIES_TO_HA_ATTRIBUTES.items(): + value = getattr(self._device_data, prop) + if value and value is not WAITING_TEXT: + attribs[attr] = value + + return attribs + + @property + def available(self) -> bool: + """Return True if entity is available.""" + from aioswitcher.consts import (STATE_OFF as SWITCHER_STATE_OFF, + STATE_ON as SWITCHER_STATE_ON) + return self._state in [SWITCHER_STATE_ON, SWITCHER_STATE_OFF] + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + async_dispatcher_connect( + self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data) + + async def async_update_data(self, device_data: 'SwitcherV2Device') -> None: + """Update the entity data.""" + if device_data: + if self._self_initiated: + self._self_initiated = False + else: + self._device_data = device_data + self._state = self._device_data.state + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs: Dict) -> None: + """Turn the entity on. + + This method must be run in the event loop and returns a coroutine. + """ + await self._control_device(True) + + async def async_turn_off(self, **kwargs: Dict) -> None: + """Turn the entity off. + + This method must be run in the event loop and returns a coroutine. + """ + await self._control_device(False) + + async def _control_device(self, send_on: bool) -> None: + """Turn the entity on or off.""" + from aioswitcher.api import SwitcherV2Api + from aioswitcher.consts import (COMMAND_OFF, COMMAND_ON, + STATE_OFF as SWITCHER_STATE_OFF, + STATE_ON as SWITCHER_STATE_ON) + + response = None # type: SwitcherV2ControlResponseMSG + async with SwitcherV2Api( + self.hass.loop, self._device_data.ip_addr, + self._device_data.phone_id, self._device_data.device_id, + self._device_data.device_password) as swapi: + response = await swapi.control_device( + COMMAND_ON if send_on else COMMAND_OFF) + + if response and response.successful: + self._self_initiated = True + self._state = \ + SWITCHER_STATE_ON if send_on else SWITCHER_STATE_OFF + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 1aadeb54909..8fc3b2476cb 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -3,8 +3,8 @@ "name": "Syncthru", "documentation": "https://www.home-assistant.io/components/syncthru", "requirements": [ - "pysyncthru==0.3.1" + "pysyncthru==0.4.2" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@nielstron"] } diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 33f57fa0371..fe95d7c7e20 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_RESOURCE, CONF_HOST, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -12,40 +13,33 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Samsung Printer' -DEFAULT_MONITORED_CONDITIONS = [ - 'toner_black', - 'toner_cyan', - 'toner_magenta', - 'toner_yellow', - 'drum_black', - 'drum_cyan', - 'drum_magenta', - 'drum_yellow', - 'tray_1', - 'tray_2', - 'tray_3', - 'tray_4', - 'tray_5', - 'output_tray_0', - 'output_tray_1', - 'output_tray_2', - 'output_tray_3', - 'output_tray_4', - 'output_tray_5', -] COLORS = [ 'black', 'cyan', 'magenta', 'yellow' ] +DRUM_COLORS = COLORS +TONER_COLORS = COLORS +TRAYS = range(1, 6) +OUTPUT_TRAYS = range(0, 6) +DEFAULT_MONITORED_CONDITIONS = [] +DEFAULT_MONITORED_CONDITIONS.extend( + ['toner_{}'.format(key) for key in TONER_COLORS] +) +DEFAULT_MONITORED_CONDITIONS.extend( + ['drum_{}'.format(key) for key in DRUM_COLORS] +) +DEFAULT_MONITORED_CONDITIONS.extend( + ['trays_{}'.format(key) for key in TRAYS] +) +DEFAULT_MONITORED_CONDITIONS.extend( + ['output_trays_{}'.format(key) for key in OUTPUT_TRAYS] +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, - vol.Optional( - CONF_NAME, - default=DEFAULT_NAME - ): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional( CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED_CONDITIONS @@ -53,48 +47,70 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -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 SyncThru component.""" - from pysyncthru import SyncThru, test_syncthru + from pysyncthru import SyncThru if discovery_info is not None: + _LOGGER.info("Discovered a new Samsung Printer at %s", + discovery_info.get(CONF_HOST)) host = discovery_info.get(CONF_HOST) name = discovery_info.get(CONF_NAME, DEFAULT_NAME) - _LOGGER.debug("Discovered a new Samsung Printer: %s", discovery_info) - # Test if the discovered device actually is a syncthru printer - if not test_syncthru(host): - _LOGGER.error("No SyncThru Printer found at %s", host) - return + # Main device, always added monitored = DEFAULT_MONITORED_CONDITIONS else: host = config.get(CONF_RESOURCE) name = config.get(CONF_NAME) monitored = config.get(CONF_MONITORED_CONDITIONS) - # Main device, always added - try: - printer = SyncThru(host) - except TypeError: - # if an exception is thrown, printer cannot be set up - return + session = aiohttp_client.async_get_clientsession(hass) + + printer = SyncThru(host, session) + # Test if the discovered device actually is a syncthru printer + # and fetch the available toner/drum/etc + try: + # No error is thrown when the device is off + # (only after user added it manually) + # therefore additional catches are inside the Sensor below + await printer.update() + supp_toner = printer.toner_status(filter_supported=True) + supp_drum = printer.drum_status(filter_supported=True) + supp_tray = printer.input_tray_status(filter_supported=True) + supp_output_tray = printer.output_tray_status() + except ValueError: + # if an exception is thrown, printer does not support syncthru + # and should not be set up + # If the printer was discovered automatically, no warning or error + # should be issued and printer should not be set up + if discovery_info is not None: + _LOGGER.info("Samsung printer at %s does not support SyncThru", + host) + return + # Otherwise, emulate printer that supports everything + supp_toner = TONER_COLORS + supp_drum = DRUM_COLORS + supp_tray = TRAYS + supp_output_tray = OUTPUT_TRAYS - printer.update() devices = [SyncThruMainSensor(printer, name)] - for key in printer.toner_status(filter_supported=True): + for key in supp_toner: if 'toner_{}'.format(key) in monitored: devices.append(SyncThruTonerSensor(printer, name, key)) - for key in printer.drum_status(filter_supported=True): + for key in supp_drum: if 'drum_{}'.format(key) in monitored: devices.append(SyncThruDrumSensor(printer, name, key)) - for key in printer.input_tray_status(filter_supported=True): + for key in supp_tray: if 'tray_{}'.format(key) in monitored: devices.append(SyncThruInputTraySensor(printer, name, key)) - for key in printer.output_tray_status(): + for key in supp_output_tray: if 'output_tray_{}'.format(key) in monitored: devices.append(SyncThruOutputTraySensor(printer, name, key)) - add_entities(devices, True) + async_add_entities(devices, True) class SyncThruSensor(Entity): @@ -143,16 +159,28 @@ class SyncThruSensor(Entity): class SyncThruMainSensor(SyncThruSensor): - """Implementation of the main sensor, monitoring the general state.""" + """Implementation of the main sensor, conducting the actual polling.""" def __init__(self, syncthru, name): """Initialize the sensor.""" super().__init__(syncthru, name) self._id_suffix = '_main' + self._active = True - def update(self): + async def async_update(self): """Get the latest data from SyncThru and update the state.""" - self.syncthru.update() + if not self._active: + return + try: + await self.syncthru.update() + except ValueError: + # if an exception is thrown, printer does not support syncthru + _LOGGER.warning( + "Configured printer at %s does not support SyncThru. " + "Consider changing your configuration", + self.syncthru.url + ) + self._active = False self._state = self.syncthru.device_status() diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 591e710a871..b79f7aed20f 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -3,7 +3,7 @@ "name": "Systemmonitor", "documentation": "https://www.home-assistant.io/components/systemmonitor", "requirements": [ - "psutil==5.6.1" + "psutil==5.6.2" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/tapsaff/manifest.json b/homeassistant/components/tapsaff/manifest.json index 6008ef38cc6..1c8e8476987 100644 --- a/homeassistant/components/tapsaff/manifest.json +++ b/homeassistant/components/tapsaff/manifest.json @@ -3,7 +3,7 @@ "name": "Tapsaff", "documentation": "https://www.home-assistant.io/components/tapsaff", "requirements": [ - "tapsaff==0.2.0" + "tapsaff==0.2.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index cf0439345dc..9cc50405bad 100644 --- a/homeassistant/components/ted5000/manifest.json +++ b/homeassistant/components/ted5000/manifest.json @@ -3,7 +3,7 @@ "name": "Ted5000", "documentation": "https://www.home-assistant.io/components/ted5000", "requirements": [ - "xmltodict==0.11.0" + "xmltodict==0.12.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 43f8a26644c..09d4fb0dd88 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -446,11 +446,15 @@ class TelegramNotificationService: params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID] # Keyboards: if ATTR_KEYBOARD in data: - from telegram import ReplyKeyboardMarkup + from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove keys = data.get(ATTR_KEYBOARD) keys = keys if isinstance(keys, list) else [keys] - params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( - [[key.strip() for key in row.split(",")] for row in keys]) + if keys: + params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( + [[key.strip() for key in row.split(",")] + for row in keys]) + else: + params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True) elif ATTR_KEYBOARD_INLINE in data: from telegram import InlineKeyboardMarkup keys = data.get(ATTR_KEYBOARD_INLINE) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 206898bfda2..ed8720c5877 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -22,7 +22,7 @@ send_message: description: Disables link previews for links in the message. example: true keyboard: - description: List of rows of commands, comma-separated, to make a custom keyboard. + description: List of rows of commands, comma-separated, to make a custom keyboard. Empty list clears a previously set keyboard. example: '["/command1, /command2", "/command3"]' inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. diff --git a/homeassistant/components/tellduslive/.translations/ca.json b/homeassistant/components/tellduslive/.translations/ca.json index 75915735882..437b9b460d2 100644 --- a/homeassistant/components/tellduslive/.translations/ca.json +++ b/homeassistant/components/tellduslive/.translations/ca.json @@ -12,7 +12,7 @@ }, "step": { "auth": { - "description": "Passos per enlla\u00e7ar el teu compte de TelldusLive:\n 1. Clica l'enlla\u00e7 de sota.\n 2. Inicia sessi\u00f3 a Telldus Live.\n 3. Autoritza **{app_name}** (clica **Yes**).\n 4. Torna aqu\u00ed i clica **SUBMIT**.\n \n [Enlla\u00e7 al compte de TelldusLive]({auth_url})", + "description": "Per enlla\u00e7ar el teu compte de TelldusLive:\n 1. Clica l'enlla\u00e7 de sota.\n 2. Inicia sessi\u00f3 a Telldus Live.\n 3. Autoritza **{app_name}** (clica **Yes**).\n 4. Torna aqu\u00ed i clica **SUBMIT**.\n \n [Enlla\u00e7 al compte de TelldusLive]({auth_url})", "title": "Autenticaci\u00f3 amb TelldusLive" }, "user": { diff --git a/homeassistant/components/tellduslive/.translations/es.json b/homeassistant/components/tellduslive/.translations/es.json index 43e3660415e..6b3cea7f484 100644 --- a/homeassistant/components/tellduslive/.translations/es.json +++ b/homeassistant/components/tellduslive/.translations/es.json @@ -18,8 +18,10 @@ "user": { "data": { "host": "Host" - } + }, + "title": "Elige el punto final." } - } + }, + "title": "Telldus Live" } } \ No newline at end of file diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index abbfd8ac92e..9233924ef1b 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -67,6 +67,11 @@ class TelldusLiveLight(TelldusLiveEntity, Light): def turn_on(self, **kwargs): """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) + if brightness == 0: + fallback_brightness = 100 + _LOGGER.info("Setting brightness to %d%%, because it was 0", + fallback_brightness) + brightness = int(fallback_brightness*255/100) self.device.dim(level=brightness) self.changed() diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 2fdcc9f1036..9c5f242684b 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -5,14 +5,14 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.cover import ( - ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, + ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA, SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, ATTR_POSITION, ATTR_TILT_POSITION) from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, - CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, + CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_DEVICE_CLASS, CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError @@ -53,6 +53,7 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, @@ -79,6 +80,7 @@ async def async_setup_platform(hass, config, async_add_entities, icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get( CONF_ENTITY_PICTURE_TEMPLATE) + device_class = device_config.get(CONF_DEVICE_CLASS) open_action = device_config.get(OPEN_ACTION) close_action = device_config.get(CLOSE_ACTION) stop_action = device_config.get(STOP_ACTION) @@ -125,7 +127,7 @@ async def async_setup_platform(hass, config, async_add_entities, covers.append( CoverTemplate( hass, - device, friendly_name, state_template, + device, friendly_name, device_class, state_template, position_template, tilt_template, icon_template, entity_picture_template, open_action, close_action, stop_action, position_action, tilt_action, @@ -143,7 +145,8 @@ async def async_setup_platform(hass, config, async_add_entities, class CoverTemplate(CoverDevice): """Representation of a Template cover.""" - def __init__(self, hass, device_id, friendly_name, state_template, + def __init__(self, hass, device_id, friendly_name, device_class, + state_template, position_template, tilt_template, icon_template, entity_picture_template, open_action, close_action, stop_action, position_action, tilt_action, @@ -157,6 +160,7 @@ class CoverTemplate(CoverDevice): self._position_template = position_template self._tilt_template = tilt_template self._icon_template = icon_template + self._device_class = device_class self._entity_picture_template = entity_picture_template self._open_script = None if open_action is not None: @@ -249,6 +253,11 @@ class CoverTemplate(CoverDevice): """Return the entity picture to use in the frontend, if any.""" return self._entity_picture + @property + def device_class(self): + """Return the device class of the cover.""" + return self._device_class + @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index e9643f36b67..068e5f630cc 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -3,7 +3,7 @@ "name": "Tensorflow", "documentation": "https://www.home-assistant.io/components/tensorflow", "requirements": [ - "numpy==1.16.2", + "numpy==1.16.3", "pillow==5.4.1", "protobuf==3.6.1" ], diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 08cdecf8569..e9073c4f98c 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -45,7 +45,7 @@ async def async_setup_platform( success = await ttn_data_storage.async_update() if not success: - return False + return devices = [] for value, unit_of_measurement in values.items(): @@ -78,8 +78,9 @@ class TtnDataSensor(Entity): if self._ttn_data_storage.data is not None: try: return round(self._state[self._value], 1) - except KeyError: - pass + except (KeyError, TypeError): + return None + return None @property def unit_of_measurement(self): @@ -124,32 +125,32 @@ class TtnDataStorage: try: session = async_get_clientsession(self._hass) with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self._hass.loop): - req = await session.get(self._url, headers=self._headers) + response = await session.get(self._url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while accessing: %s", self._url) - return False + return None - status = req.status + status = response.status if status == 204: _LOGGER.error("The device is not available: %s", self._device_id) - return False + return None if status == 401: _LOGGER.error( "Not authorized for Application ID: %s", self._app_id) - return False + return None if status == 404: _LOGGER.error("Application ID is not available: %s", self._app_id) - return False + return None - data = await req.json() + data = await response.json() self.data = data[-1] for value in self._values.items(): if value[0] not in self.data.keys(): _LOGGER.warning("Value not available: %s", value[0]) - return req + return response diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 8c6982f9764..6562fa49cde 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "name": "Tibber", "documentation": "https://www.home-assistant.io/components/tibber", "requirements": [ - "pyTibber==0.10.1" + "pyTibber==0.10.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/tplink/.translations/cs.json b/homeassistant/components/tplink/.translations/cs.json new file mode 100644 index 00000000000..1d9fb41fc8c --- /dev/null +++ b/homeassistant/components/tplink/.translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py index a8844979e5e..8bcbf0cd2b2 100644 --- a/homeassistant/components/tplink_lte/notify.py +++ b/homeassistant/components/tplink_lte/notify.py @@ -7,7 +7,7 @@ from homeassistant.components.notify import ( ATTR_TARGET, BaseNotificationService) from homeassistant.const import CONF_RECIPIENT -from ..tplink_lte import DATA_KEY +from . import DATA_KEY _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 1fef18a6ae1..d2990e178ab 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -25,6 +25,7 @@ ATTR_MOTION = 'motion' ATTR_SPEED = 'speed' ATTR_TRACKER = 'tracker' ATTR_TRACCAR_ID = 'traccar_id' +ATTR_STATUS = 'status' EVENT_DEVICE_MOVING = 'device_moving' EVENT_COMMAND_RESULT = 'command_result' @@ -108,53 +109,74 @@ class TraccarScanner: self._scan_interval = scan_interval self._async_see = async_see self._api = api + self.connected = False self._hass = hass async def async_init(self): """Further initialize connection to Traccar.""" await self._api.test_connection() - if self._api.authenticated: - await self._async_update() - async_track_time_interval(self._hass, - self._async_update, - self._scan_interval) + if self._api.connected and not self._api.authenticated: + _LOGGER.error("Authentication for Traccar failed") + return False - return self._api.authenticated + await self._async_update() + async_track_time_interval(self._hass, + self._async_update, + self._scan_interval) + return True async def _async_update(self, now=None): """Update info from Traccar.""" - _LOGGER.debug('Updating device data.') + if not self.connected: + _LOGGER.debug('Testing connection to Traccar') + await self._api.test_connection() + self.connected = self._api.connected + if self.connected: + _LOGGER.info("Connection to Traccar restored") + else: + return + _LOGGER.debug('Updating device data') await self._api.get_device_info(self._custom_attributes) self._hass.async_create_task(self.import_device_data()) if self._event_types: self._hass.async_create_task(self.import_events()) + self.connected = self._api.connected async def import_device_data(self): """Import device data from Traccar.""" - for devicename in self._api.device_info: - device = self._api.device_info[devicename] + for device_unique_id in self._api.device_info: + device_info = self._api.device_info[device_unique_id] + device = None attr = {} attr[ATTR_TRACKER] = 'traccar' - if device.get('address') is not None: - attr[ATTR_ADDRESS] = device['address'] - if device.get('geofence') is not None: - attr[ATTR_GEOFENCE] = device['geofence'] - if device.get('category') is not None: - attr[ATTR_CATEGORY] = device['category'] - if device.get('speed') is not None: - attr[ATTR_SPEED] = device['speed'] - if device.get('battery') is not None: - attr[ATTR_BATTERY_LEVEL] = device['battery'] - if device.get('motion') is not None: - attr[ATTR_MOTION] = device['motion'] - if device.get('traccar_id') is not None: - attr[ATTR_TRACCAR_ID] = device['traccar_id'] + if device_info.get('address') is not None: + attr[ATTR_ADDRESS] = device_info['address'] + if device_info.get('geofence') is not None: + attr[ATTR_GEOFENCE] = device_info['geofence'] + if device_info.get('category') is not None: + attr[ATTR_CATEGORY] = device_info['category'] + if device_info.get('speed') is not None: + attr[ATTR_SPEED] = device_info['speed'] + if device_info.get('battery') is not None: + attr[ATTR_BATTERY_LEVEL] = device_info['battery'] + if device_info.get('motion') is not None: + attr[ATTR_MOTION] = device_info['motion'] + if device_info.get('traccar_id') is not None: + attr[ATTR_TRACCAR_ID] = device_info['traccar_id'] + for dev in self._api.devices: + if dev['id'] == device_info['traccar_id']: + device = dev + break + if device is not None and device.get('status') is not None: + attr[ATTR_STATUS] = device['status'] for custom_attr in self._custom_attributes: - if device.get(custom_attr) is not None: - attr[custom_attr] = device[custom_attr] + if device_info.get(custom_attr) is not None: + attr[custom_attr] = device_info[custom_attr] await self._async_see( - dev_id=slugify(device['device_id']), - gps=(device.get('latitude'), device.get('longitude')), + dev_id=slugify(device_info['device_id']), + gps=(device_info.get('latitude'), + device_info.get('longitude')), + gps_accuracy=(device_info.get('accuracy')), attributes=attr) async def import_events(self): diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 57bd1383363..0f9aa6e8464 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -3,11 +3,11 @@ "name": "Traccar", "documentation": "https://www.home-assistant.io/components/traccar", "requirements": [ - "pytraccar==0.5.0", + "pytraccar==0.8.0", "stringcase==1.2.0" ], "dependencies": [], "codeowners": [ "@ludeeus" ] -} +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/es.json b/homeassistant/components/tradfri/.translations/es.json index 991832c9053..b7bfd4ecfa4 100644 --- a/homeassistant/components/tradfri/.translations/es.json +++ b/homeassistant/components/tradfri/.translations/es.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "No se puede conectar a la puerta de enlace.", - "invalid_key": "No se pudo registrar con la clave proporcionada. Si esto sigue sucediendo, intente reiniciar la puerta de enlace.", + "invalid_key": "No se ha podido registrar con la clave proporcionada. Si esto sigue ocurriendo, intenta reiniciar el gateway.", "timeout": "Tiempo de espera agotado validando el c\u00f3digo." }, "step": { @@ -14,7 +14,7 @@ "host": "Host", "security_code": "C\u00f3digo de seguridad" }, - "description": "Puede encontrar el c\u00f3digo de seguridad en la parte posterior de su puerta de enlace.", + "description": "Puedes encontrar el c\u00f3digo de seguridad en la parte posterior de tu gateway.", "title": "Introduzca el c\u00f3digo de seguridad" } }, diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 865d7064db2..a176c80c70b 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -3,7 +3,7 @@ "name": "Trend", "documentation": "https://www.home-assistant.io/components/trend", "requirements": [ - "numpy==1.16.2" + "numpy==1.16.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/twilio/.translations/es.json b/homeassistant/components/twilio/.translations/es.json index 6dbeff23c75..8112c2a47c3 100644 --- a/homeassistant/components/twilio/.translations/es.json +++ b/homeassistant/components/twilio/.translations/es.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Twilio.", + "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Twilio.", "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." }, "create_entry": { - "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Twilio] ( {twilio_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application / x-www-form-urlencoded \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + "default": "Para enviar eventos a Home Assistant debes configurar los [Webhooks en Twilio]({twilio_url}). \n\n Completa la siguiente informaci\u00f3n: \n\n - URL: `{webhook_url}` \n - M\u00e9todo: POST \n - Tipo de contenido: application/x-www-form-urlencoded \n\nConsulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." }, "step": { "user": { diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py index 8e610a4f51c..b81a2320b5e 100644 --- a/homeassistant/components/ubee/device_tracker.py +++ b/homeassistant/components/ubee/device_tracker.py @@ -18,7 +18,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): + vol.Any( + 'EVW32C-0N', + 'EVW320B', + 'EVW3200-Wifi', + 'EVW3226@UPC', + ), }) diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json index 524dcb1d77b..f9f17e41546 100644 --- a/homeassistant/components/ubee/manifest.json +++ b/homeassistant/components/ubee/manifest.json @@ -3,7 +3,7 @@ "name": "Ubee", "documentation": "https://www.home-assistant.io/components/ubee", "requirements": [ - "pyubee==0.5" + "pyubee==0.6" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index fdeb15ee4ad..4d65a0d223a 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -1,7 +1,7 @@ """Constants for the UniFi component.""" import logging -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) DOMAIN = 'unifi' CONTROLLER_ID = '{host}-{site}' diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json index 926fb9acf88..36a06ac3204 100644 --- a/homeassistant/components/upc_connect/manifest.json +++ b/homeassistant/components/upc_connect/manifest.json @@ -3,7 +3,7 @@ "name": "Upc connect", "documentation": "https://www.home-assistant.io/components/upc_connect", "requirements": [ - "defusedxml==0.5.0" + "defusedxml==0.6.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/upnp/.translations/de.json b/homeassistant/components/upnp/.translations/de.json index 51faf56367d..907bfffbeea 100644 --- a/homeassistant/components/upnp/.translations/de.json +++ b/homeassistant/components/upnp/.translations/de.json @@ -8,6 +8,10 @@ "no_sensors_or_port_mapping": "Aktiviere mindestens Sensoren oder Port-Mapping", "single_instance_allowed": "Es ist nur eine einzige Konfiguration von UPnP/IGD erforderlich." }, + "error": { + "one": "Ein", + "other": "andere" + }, "step": { "confirm": { "description": "M\u00f6chten Sie UPnP/IGD einrichten?", diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 01f6d6159f0..fd2aa994ca4 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,5 +1,6 @@ """Open ports in your router for Home Assistant and provide statistics.""" from ipaddress import ip_address +from operator import itemgetter import voluptuous as vol @@ -88,6 +89,8 @@ async def async_discover_and_construct(hass, udn=None) -> Device: _LOGGER.warning('Wanted UPnP/IGD device with UDN "%s" not found, ' 'aborting', udn) return None + # ensure we're always taking the latest + filtered = sorted(filtered, key=itemgetter('st'), reverse=True) discovery_info = filtered[0] else: # get the first/any diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 9d2957660dc..5ca321c9d08 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -7,5 +7,5 @@ CONF_HASS = 'hass' CONF_LOCAL_IP = 'local_ip' CONF_PORTS = 'ports' DOMAIN = 'upnp' -LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) SIGNAL_REMOVE_SENSOR = 'upnp_remove_sensor' diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index bac2587cdc9..9f1f4a7200a 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -3,7 +3,7 @@ "name": "Velux", "documentation": "https://www.home-assistant.io/components/velux", "requirements": [ - "pyvlx==0.2.10" + "pyvlx==0.2.11" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index bba754d135f..9bd0678c904 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -3,7 +3,7 @@ "name": "Vesync", "documentation": "https://www.home-assistant.io/components/vesync", "requirements": [ - "pyvesync_v2==0.9.6" + "pyvesync_v2==0.9.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index ac589de841a..c65204d78e8 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -3,8 +3,8 @@ "name": "Vizio", "documentation": "https://www.home-assistant.io/components/vizio", "requirements": [ - "pyvizio==0.0.4" + "pyvizio==0.0.7" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@raman325"] } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 7b47a388325..68374ed59b9 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,18 +1,30 @@ -"""Vizio SmartCast TV support.""" +"""Vizio SmartCast Device support.""" from datetime import timedelta import logging - import voluptuous as vol - from homeassistant import util from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) + MediaPlayerDevice, + PLATFORM_SCHEMA +) from homeassistant.components.media_player.const import ( - SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP +) from homeassistant.const import ( - CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) + CONF_ACCESS_TOKEN, + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON +) from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -22,6 +34,7 @@ CONF_VOLUME_STEP = 'volume_step' DEFAULT_NAME = 'Vizio SmartCast' DEFAULT_VOLUME_STEP = 1 +DEFAULT_DEVICE_CLASS = 'tv' DEVICE_ID = 'pyvizio' DEVICE_NAME = 'Python Vizio' @@ -30,36 +43,71 @@ ICON = 'mdi:television' MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -SUPPORTED_COMMANDS = SUPPORT_TURN_ON | SUPPORT_TURN_OFF \ - | SUPPORT_SELECT_SOURCE \ - | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \ - | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP \ - | SUPPORT_VOLUME_SET +COMMON_SUPPORTED_COMMANDS = ( + SUPPORT_SELECT_SOURCE | + SUPPORT_TURN_ON | + SUPPORT_TURN_OFF | + SUPPORT_VOLUME_MUTE | + SUPPORT_VOLUME_SET | + SUPPORT_VOLUME_STEP +) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SUPPRESS_WARNING, default=False): cv.boolean, - vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): - vol.All(vol.Coerce(int), vol.Range(min=1, max=10)), -}) +SUPPORTED_COMMANDS = { + 'soundbar': COMMON_SUPPORTED_COMMANDS, + 'tv': ( + COMMON_SUPPORTED_COMMANDS | + SUPPORT_NEXT_TRACK | + SUPPORT_PREVIOUS_TRACK + ) +} + + +def validate_auth(config): + """Validate presence of CONF_ACCESS_TOKEN when CONF_DEVICE_CLASS=tv.""" + token = config.get(CONF_ACCESS_TOKEN) + if config[CONF_DEVICE_CLASS] == 'tv' and (token is None or token == ''): + raise vol.Invalid( + "When '{}' is 'tv' then '{}' is required.".format( + CONF_DEVICE_CLASS, + CONF_ACCESS_TOKEN, + ), + path=[CONF_ACCESS_TOKEN], + ) + return config + + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SUPPRESS_WARNING, default=False): cv.boolean, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): + vol.All(cv.string, vol.Lower, vol.In(['tv', 'soundbar'])), + vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): + vol.All(vol.Coerce(int), vol.Range(min=1, max=10)), + }), + validate_auth, +) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the VizioTV media player platform.""" - host = config.get(CONF_HOST) + """Set up the Vizio media player platform.""" + host = config[CONF_HOST] token = config.get(CONF_ACCESS_TOKEN) - name = config.get(CONF_NAME) - volume_step = config.get(CONF_VOLUME_STEP) - - device = VizioDevice(host, token, name, volume_step) + name = config[CONF_NAME] + volume_step = config[CONF_VOLUME_STEP] + device_type = config[CONF_DEVICE_CLASS] + device = VizioDevice(host, token, name, volume_step, device_type) if device.validate_setup() is False: - _LOGGER.error("Failed to set up Vizio TV platform, " - "please check if host and API key are correct") + fail_auth_msg = "" + if token is not None and token != '': + fail_auth_msg = " and auth token is correct" + _LOGGER.error("Failed to set up Vizio platform, please check if host " + "is valid and available%s", fail_auth_msg) return - if config.get(CONF_SUPPRESS_WARNING): + if config[CONF_SUPPRESS_WARNING]: from requests.packages import urllib3 _LOGGER.warning("InsecureRequestWarning is disabled " "because of Vizio platform configuration") @@ -68,22 +116,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class VizioDevice(MediaPlayerDevice): - """Media Player implementation which performs REST requests to TV.""" + """Media Player implementation which performs REST requests to device.""" - def __init__(self, host, token, name, volume_step): + def __init__(self, host, token, name, volume_step, device_type): """Initialize Vizio device.""" import pyvizio - self._device = pyvizio.Vizio(DEVICE_ID, host, DEFAULT_NAME, token) + self._name = name self._state = None self._volume_level = None self._volume_step = volume_step self._current_input = None self._available_inputs = None + self._device_type = device_type + self._supported_commands = SUPPORTED_COMMANDS[device_type] + self._device = pyvizio.Vizio(DEVICE_ID, host, DEFAULT_NAME, token, + device_type) + self._max_volume = float(self._device.get_max_volume()) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update(self): - """Retrieve latest state of the TV.""" + """Retrieve latest state of the device.""" is_on = self._device.get_power_state() if is_on: @@ -91,7 +144,7 @@ class VizioDevice(MediaPlayerDevice): volume = self._device.get_current_volume() if volume is not None: - self._volume_level = float(volume) / 100. + self._volume_level = float(volume) / self._max_volume input_ = self._device.get_current_input() if input_ is not None: @@ -113,40 +166,40 @@ class VizioDevice(MediaPlayerDevice): @property def state(self): - """Return the state of the TV.""" + """Return the state of the device.""" return self._state @property def name(self): - """Return the name of the TV.""" + """Return the name of the device.""" return self._name @property def volume_level(self): - """Return the volume level of the TV.""" + """Return the volume level of the device.""" return self._volume_level @property def source(self): - """Return current input of the TV.""" + """Return current input of the device.""" return self._current_input @property def source_list(self): - """Return list of available inputs of the TV.""" + """Return list of available inputs of the device.""" return self._available_inputs @property def supported_features(self): - """Flag TV features that are supported.""" - return SUPPORTED_COMMANDS + """Flag device features that are supported.""" + return self._supported_commands def turn_on(self): - """Turn the TV player on.""" + """Turn the device on.""" self._device.pow_on() def turn_off(self): - """Turn the TV player off.""" + """Turn the device off.""" self._device.pow_off() def mute_volume(self, mute): @@ -169,27 +222,27 @@ class VizioDevice(MediaPlayerDevice): self._device.input_switch(source) def volume_up(self): - """Increasing volume of the TV.""" - self._volume_level += self._volume_step / 100. + """Increasing volume of the device.""" + self._volume_level += self._volume_step / self._max_volume self._device.vol_up(num=self._volume_step) def volume_down(self): - """Decreasing volume of the TV.""" - self._volume_level -= self._volume_step / 100. + """Decreasing volume of the device.""" + self._volume_level -= self._volume_step / self._max_volume self._device.vol_down(num=self._volume_step) def validate_setup(self): - """Validate if host is available and key is correct.""" + """Validate if host is available and auth token is correct.""" return self._device.get_current_volume() is not None def set_volume_level(self, volume): """Set volume level.""" if self._volume_level is not None: if volume > self._volume_level: - num = int(100*(volume - self._volume_level)) + num = int(self._max_volume * (volume - self._volume_level)) self._volume_level = volume self._device.vol_up(num=num) elif volume < self._volume_level: - num = int(100*(self._volume_level - volume)) + num = int(self._max_volume * (self._volume_level - volume)) self._volume_level = volume self._device.vol_down(num=num) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index d9834758c80..84178beef8b 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -142,11 +142,14 @@ def handle_get_states(hass, connection, msg): Async friendly. """ - entity_perm = connection.user.permissions.check_entity - states = [ - state for state in hass.states.async_all() - if entity_perm(state.entity_id, 'read') - ] + if connection.user.permissions.access_all_entities('read'): + states = hass.states.async_all() + else: + entity_perm = connection.user.permissions.check_entity + states = [ + state for state in hass.states.async_all() + if entity_perm(state.entity_id, 'read') + ] connection.send_message(messages.result_message( msg['id'], states)) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 0fc446390b7..85051dcae73 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -200,7 +200,8 @@ class WebSocketHandler: else: self._logger.warning("Disconnected: %s", disconnect_warn) - self.hass.data[DATA_CONNECTIONS] -= 1 + if connection is not None: + self.hass.data[DATA_CONNECTIONS] -= 1 self.hass.helpers.dispatcher.async_dispatcher_send( SIGNAL_WEBSOCKET_DISCONNECTED) diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index b98b21d184e..f1849fda539 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -10,6 +10,9 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED) from homeassistant.components.persistent_notification import ( EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) +from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. @@ -20,4 +23,7 @@ SUBSCRIBE_WHITELIST = { EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, + EVENT_AREA_REGISTRY_UPDATED, + EVENT_DEVICE_REGISTRY_UPDATED, + EVENT_ENTITY_REGISTRY_UPDATED, } diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 4e25fc4fd0d..30fd0b86e1c 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -329,8 +329,10 @@ def setup(hass, config): return True pywink.set_user_agent(USER_AGENT) + sub_details = pywink.get_subscription_details() hass.data[DOMAIN]['pubnub'] = PubNubSubscriptionHandler( - pywink.get_subscription_key()) + sub_details[0], + origin=sub_details[1]) def _subscribe(): hass.data[DOMAIN]['pubnub'].subscribe() diff --git a/homeassistant/components/wink/manifest.json b/homeassistant/components/wink/manifest.json index c8951637bde..c837a46e011 100644 --- a/homeassistant/components/wink/manifest.json +++ b/homeassistant/components/wink/manifest.json @@ -3,8 +3,8 @@ "name": "Wink", "documentation": "https://www.home-assistant.io/components/wink", "requirements": [ - "pubnubsub-handler==1.0.3", - "python-wink==1.10.3" + "pubnubsub-handler==1.0.4", + "python-wink==1.10.5" ], "dependencies": ["configurator"], "codeowners": [] diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index a79f2960497..8620b1dc34c 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi aqara", "documentation": "https://www.home-assistant.io/components/xiaomi_aqara", "requirements": [ - "PyXiaomiGateway==0.12.2" + "PyXiaomiGateway==0.12.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index d8e4e5c4da6..3d2c3a5e911 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -7,6 +7,7 @@ ], "dependencies": [], "codeowners": [ - "@fabaff" + "@fabaff", + "@flowolf" ] } diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8c2c9c957c6..dd89ed27f53 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -184,15 +184,14 @@ class YeelightDevice: def bulb(self): """Return bulb device.""" if self._bulb_device is None: - import yeelight + from yeelight import Bulb, BulbException try: - self._bulb_device = yeelight.Bulb(self._ipaddr, - model=self._model) + self._bulb_device = Bulb(self._ipaddr, model=self._model) # force init for type self.update() self._available = True - except yeelight.BulbException as ex: + except BulbException as ex: self._available = False _LOGGER.error("Failed to connect to bulb %s, %s: %s", self._ipaddr, self._name, ex) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index fa62bdc35d7..8d48e695b31 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -92,12 +92,12 @@ def _transitions_config_parser(transitions): def _parse_custom_effects(effects_config): - import yeelight + from yeelight import Flow effects = {} for config in effects_config: params = config[CONF_FLOW_PARAMS] - action = yeelight.Flow.actions[params[ATTR_ACTION]] + action = Flow.actions[params[ATTR_ACTION]] transitions = _transitions_config_parser( params[ATTR_TRANSITIONS]) @@ -113,11 +113,11 @@ def _parse_custom_effects(effects_config): def _cmd(func): """Define a wrapper to catch exceptions from the bulb.""" def _wrap(self, *args, **kwargs): - import yeelight + from yeelight import BulbException try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return func(self, *args, **kwargs) - except yeelight.BulbException as ex: + except BulbException as ex: _LOGGER.error("Error when calling %s: %s", func, ex) return _wrap @@ -347,15 +347,14 @@ class YeelightLight(Light): def update(self) -> None: """Update properties from the bulb.""" - import yeelight + from yeelight import BulbType, enums bulb_type = self._bulb.bulb_type - if bulb_type == yeelight.BulbType.Color: + if bulb_type == BulbType.Color: self._supported_features = SUPPORT_YEELIGHT_RGB - elif self.light_type == yeelight.enums.LightType.Ambient: + elif self.light_type == enums.LightType.Ambient: self._supported_features = SUPPORT_YEELIGHT_RGB - elif bulb_type in (yeelight.BulbType.WhiteTemp, - yeelight.BulbType.WhiteTempMood): + elif bulb_type in (BulbType.WhiteTemp, BulbType.WhiteTempMood): if self._is_nightlight_enabled: self._supported_features = SUPPORT_YEELIGHT else: @@ -368,7 +367,7 @@ class YeelightLight(Light): self._max_mireds = \ kelvin_to_mired(model_specs['color_temp']['min']) - if bulb_type == yeelight.BulbType.WhiteTempMood: + if bulb_type == BulbType.WhiteTempMood: self._is_on = self._get_property('main_power') == 'on' else: self._is_on = self._get_property('power') == 'on' diff --git a/homeassistant/components/yr/manifest.json b/homeassistant/components/yr/manifest.json index ec12f6cdac4..88daadd35aa 100644 --- a/homeassistant/components/yr/manifest.json +++ b/homeassistant/components/yr/manifest.json @@ -3,7 +3,7 @@ "name": "Yr", "documentation": "https://www.home-assistant.io/components/yr", "requirements": [ - "xmltodict==0.11.0" + "xmltodict==0.12.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index bd7cf3ec0d6..7ef9b250363 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -3,7 +3,7 @@ "name": "Zeroconf", "documentation": "https://www.home-assistant.io/components/zeroconf", "requirements": [ - "zeroconf==0.21.3" + "zeroconf==0.22.0" ], "dependencies": [ "api" diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index 1d67ddbd581..4d1a55eaa09 100644 --- a/homeassistant/components/zestimate/manifest.json +++ b/homeassistant/components/zestimate/manifest.json @@ -3,7 +3,7 @@ "name": "Zestimate", "documentation": "https://www.home-assistant.io/components/zestimate", "requirements": [ - "xmltodict==0.11.0" + "xmltodict==0.12.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 036422d6800..10467b20cfa 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -67,6 +67,11 @@ class ZestimateDataSensor(Entity): self.address = None self._state = None + @property + def unique_id(self): + """Return the ZPID.""" + return self.params['zpid'] + @property def name(self): """Return the name of the sensor.""" diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 64760170150..87c405873ee 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,6 @@ from .core.const import ( CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, DEFAULT_BAUDRATE, DEFAULT_RADIO_TYPE, DOMAIN, ENABLE_QUIRKS, RadioType) -from .core.patches import apply_cluster_listener_patch from .core.registries import establish_device_mappings DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ @@ -91,10 +90,6 @@ async def async_setup_entry(hass, config_entry): # pylint: disable=W0611, W0612 import zhaquirks # noqa - # patch zigpy listener to prevent flooding logs with warnings due to - # how zigpy implemented its listeners - apply_cluster_listener_patch() - zha_gateway = ZHAGateway(hass, config) await zha_gateway.async_initialize(config_entry) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 10370c42c66..1845ae8e999 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -167,6 +167,11 @@ class ZigbeeChannel: async def async_initialize(self, from_cache): """Initialize channel.""" + _LOGGER.debug( + 'initializing channel: %s from_cache: %s', + self._channel_name, + from_cache + ) self._status = ChannelStatus.INITIALIZED @callback diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 061541d4dae..470cd6b38cf 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/zha/ import logging from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from . import ZigbeeChannel, parse_and_log_command, MAINS_POWERED from ..helpers import get_attr_id_by_name from ..const import ( @@ -26,6 +27,7 @@ class OnOffChannel(ZigbeeChannel): """Initialize OnOffChannel.""" super().__init__(cluster, device) self._state = None + self._off_listener = None @callback def cluster_command(self, tsn, command_id, args): @@ -40,11 +42,32 @@ class OnOffChannel(ZigbeeChannel): if cmd in ('off', 'off_with_effect'): self.attribute_updated(self.ON_OFF, False) - elif cmd in ('on', 'on_with_recall_global_scene', 'on_with_timed_off'): + elif cmd in ('on', 'on_with_recall_global_scene'): self.attribute_updated(self.ON_OFF, True) + elif cmd == 'on_with_timed_off': + should_accept = args[0] + on_time = args[1] + # 0 is always accept 1 is only accept when already on + if should_accept == 0 or (should_accept == 1 and self._state): + if self._off_listener is not None: + self._off_listener() + self._off_listener = None + self.attribute_updated(self.ON_OFF, True) + if on_time > 0: + self._off_listener = async_call_later( + self.device.hass, + (on_time / 10), # value is in 10ths of a second + self.set_to_off + ) elif cmd == 'toggle': self.attribute_updated(self.ON_OFF, not bool(self._state)) + @callback + def set_to_off(self, *_): + """Set the state to off.""" + self._off_listener = None + self.attribute_updated(self.ON_OFF, False) + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" @@ -201,3 +224,5 @@ class PowerConfigurationChannel(ZigbeeChannel): 'battery_percentage_remaining', from_cache=from_cache) await self.get_attribute_value( 'battery_voltage', from_cache=from_cache) + await self.get_attribute_value( + 'battery_quantity', from_cache=from_cache) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 435ab25acc6..1a619dff981 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -200,10 +200,50 @@ class ZHADevice: self.cluster_channels[cluster_channel.name] = cluster_channel self._all_channels.append(cluster_channel) + def get_channels_to_configure(self): + """Get a deduped list of channels for configuration. + + This goes through all channels and gets a unique list of channels to + configure. It first assembles a unique list of channels that are part + of entities while stashing relay channels off to the side. It then + takse the stashed relay channels and adds them to the list of channels + that will be returned if there isn't a channel in the list for that + cluster already. This is done to ensure each cluster is only configured + once. + """ + channel_keys = [] + channels = [] + relay_channels = self._relay_channels.values() + + def get_key(channel): + channel_key = "ZDO" + if hasattr(channel.cluster, 'cluster_id'): + channel_key = "{}_{}".format( + channel.cluster.endpoint.endpoint_id, + channel.cluster.cluster_id + ) + return channel_key + + # first we get all unique non event channels + for channel in self.all_channels: + c_key = get_key(channel) + if c_key not in channel_keys and channel not in relay_channels: + channel_keys.append(c_key) + channels.append(channel) + + # now we get event channels that still need their cluster configured + for channel in relay_channels: + channel_key = get_key(channel) + if channel_key not in channel_keys: + channel_keys.append(channel_key) + channels.append(channel) + return channels + async def async_configure(self): """Configure the device.""" _LOGGER.debug('%s: started configuration', self.name) - await self._execute_channel_tasks('async_configure') + await self._execute_channel_tasks( + self.get_channels_to_configure(), 'async_configure') _LOGGER.debug('%s: completed configuration', self.name) entry = self.gateway.zha_storage.async_create_or_update(self) _LOGGER.debug('%s: stored in registry: %s', self.name, entry) @@ -211,7 +251,8 @@ class ZHADevice: async def async_initialize(self, from_cache=False): """Initialize channels.""" _LOGGER.debug('%s: started initialization', self.name) - await self._execute_channel_tasks('async_initialize', from_cache) + await self._execute_channel_tasks( + self.all_channels, 'async_initialize', from_cache) _LOGGER.debug( '%s: power source: %s', self.name, @@ -220,16 +261,17 @@ class ZHADevice: self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) - async def _execute_channel_tasks(self, task_name, *args): + async def _execute_channel_tasks(self, channels, task_name, *args): """Gather and execute a set of CHANNEL tasks.""" channel_tasks = [] semaphore = asyncio.Semaphore(3) zdo_task = None - for channel in self.all_channels: + for channel in channels: if channel.name == ZDO_CHANNEL: # pylint: disable=E1111 - zdo_task = self._async_create_task( - semaphore, channel, task_name, *args) + if zdo_task is None: # We only want to do this once + zdo_task = self._async_create_task( + semaphore, channel, task_name, *args) else: channel_tasks.append( self._async_create_task( @@ -279,15 +321,18 @@ class ZHADevice: } @callback - def async_get_zha_clusters(self): - """Get zigbee home automation clusters for this device.""" - from zigpy.profiles.zha import PROFILE_ID + def async_get_std_clusters(self): + """Get ZHA and ZLL clusters for this device.""" + from zigpy.profiles import zha, zll return { ep_id: { IN: endpoint.in_clusters, OUT: endpoint.out_clusters } for (ep_id, endpoint) in self._zigpy_device.endpoints.items() - if ep_id != 0 and endpoint.profile_id == PROFILE_ID + if ep_id != 0 and endpoint.profile_id in ( + zha.PROFILE_ID, + zll.PROFILE_ID + ) } @callback diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index f5bd6ee99f2..e81fa53020d 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -8,6 +8,8 @@ https://home-assistant.io/components/zha/ import logging from homeassistant import const as ha_const +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .channels import ( @@ -45,7 +47,7 @@ def async_process_endpoint( return component = None - profile_clusters = ([], []) + profile_clusters = [] device_key = "{}-{}".format(device.ieee, endpoint_id) node_config = {} if CONF_DEVICE_CONFIG in config: @@ -54,22 +56,22 @@ def async_process_endpoint( ) if endpoint.profile_id in zigpy.profiles.PROFILES: - profile = zigpy.profiles.PROFILES[endpoint.profile_id] if DEVICE_CLASS.get(endpoint.profile_id, {}).get( endpoint.device_type, None): - profile_clusters = profile.CLUSTERS[endpoint.device_type] profile_info = DEVICE_CLASS[endpoint.profile_id] component = profile_info[endpoint.device_type] if ha_const.CONF_TYPE in node_config: component = node_config[ha_const.CONF_TYPE] - profile_clusters = COMPONENT_CLUSTERS[component] - if component and component in COMPONENTS: - profile_match = _async_handle_profile_match( - hass, endpoint, profile_clusters, zha_device, - component, device_key, is_new_join) - discovery_infos.append(profile_match) + if component and component in COMPONENTS and \ + component in COMPONENT_CLUSTERS: + profile_clusters = COMPONENT_CLUSTERS[component] + if profile_clusters: + profile_match = _async_handle_profile_match( + hass, endpoint, profile_clusters, zha_device, + component, device_key, is_new_join) + discovery_infos.append(profile_match) discovery_infos.extend(_async_handle_single_cluster_matches( hass, @@ -118,10 +120,10 @@ def _async_handle_profile_match(hass, endpoint, profile_clusters, zha_device, component, device_key, is_new_join): """Dispatch a profile match to the appropriate HA component.""" in_clusters = [endpoint.in_clusters[c] - for c in profile_clusters[0] + for c in profile_clusters if c in endpoint.in_clusters] out_clusters = [endpoint.out_clusters[c] - for c in profile_clusters[1] + for c in profile_clusters if c in endpoint.out_clusters] channels = [] @@ -141,12 +143,9 @@ def _async_handle_profile_match(hass, endpoint, profile_clusters, zha_device, 'component': component } - if component == 'binary_sensor': + if component == BINARY_SENSOR: discovery_info.update({SENSOR_TYPE: UNKNOWN}) - cluster_ids = [] - cluster_ids.extend(profile_clusters[0]) - cluster_ids.extend(profile_clusters[1]) - for cluster_id in cluster_ids: + for cluster_id in profile_clusters: if cluster_id in BINARY_SENSOR_TYPES: discovery_info.update({ SENSOR_TYPE: BINARY_SENSOR_TYPES.get( @@ -174,7 +173,7 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, is_new_join, )) - if cluster.cluster_id not in profile_clusters[0]: + if cluster.cluster_id not in profile_clusters: cluster_match_results.append(_async_handle_single_cluster_match( hass, zha_device, @@ -185,7 +184,7 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, )) for cluster in endpoint.out_clusters.values(): - if cluster.cluster_id not in profile_clusters[1]: + if cluster.cluster_id not in profile_clusters: cluster_match_results.append(_async_handle_single_cluster_match( hass, zha_device, @@ -244,11 +243,11 @@ def _async_handle_single_cluster_match(hass, zha_device, cluster, device_key, 'component': component } - if component == 'sensor': + if component == SENSOR: discovery_info.update({ SENSOR_TYPE: SENSOR_TYPES.get(cluster.cluster_id, GENERIC) }) - if component == 'binary_sensor': + if component == BINARY_SENSOR: discovery_info.update({ SENSOR_TYPE: BINARY_SENSOR_TYPES.get(cluster.cluster_id, UNKNOWN) }) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 17c7c6f878f..daf14297ec1 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -93,6 +93,8 @@ class ZHAGateway: init_tasks = [] for device in self.application_controller.devices.values(): + if device.nwk == 0x0000: + continue init_tasks.append(self.async_device_initialized(device, False)) await asyncio.gather(*init_tasks) @@ -259,17 +261,25 @@ class ZHAGateway: """Handle device joined and basic information discovered (async).""" zha_device = self._async_get_or_create_device(device, is_new_join) - discovery_infos = [] - for endpoint_id, endpoint in device.endpoints.items(): - async_process_endpoint( - self._hass, self._config, endpoint_id, endpoint, - discovery_infos, device, zha_device, is_new_join + is_rejoin = False + if zha_device.status is not DeviceStatus.INITIALIZED: + discovery_infos = [] + for endpoint_id, endpoint in device.endpoints.items(): + async_process_endpoint( + self._hass, self._config, endpoint_id, endpoint, + discovery_infos, device, zha_device, is_new_join + ) + if endpoint_id != 0: + for cluster in endpoint.in_clusters.values(): + cluster.bind_only = False + for cluster in endpoint.out_clusters.values(): + cluster.bind_only = True + else: + is_rejoin = is_new_join is True + _LOGGER.debug( + 'skipping discovery for previously discovered device: %s', + "{} - is rejoin: {}".format(zha_device.ieee, is_rejoin) ) - if endpoint_id != 0: - for cluster in endpoint.in_clusters.values(): - cluster.bind_only = False - for cluster in endpoint.out_clusters.values(): - cluster.bind_only = True if is_new_join: # configure the device @@ -290,15 +300,16 @@ class ZHAGateway: else: await zha_device.async_initialize(from_cache=True) - for discovery_info in discovery_infos: - async_dispatch_discovery_info( - self._hass, - is_new_join, - discovery_info - ) + if not is_rejoin: + for discovery_info in discovery_infos: + async_dispatch_discovery_info( + self._hass, + is_new_join, + discovery_info + ) - device_entity = async_create_device_entity(zha_device) - await self._component.async_add_entities([device_entity]) + device_entity = async_create_device_entity(zha_device) + await self._component.async_add_entities([device_entity]) if is_new_join: device_info = async_get_device_info(self._hass, zha_device) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index ef7c2df6ce0..ed9f3e9c86a 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -170,8 +170,8 @@ def get_attr_id_by_name(cluster, attr_name): async def get_matched_clusters(source_zha_device, target_zha_device): """Get matched input/output cluster pairs for 2 devices.""" - source_clusters = source_zha_device.async_get_zha_clusters() - target_clusters = target_zha_device.async_get_zha_clusters() + source_clusters = source_zha_device.async_get_std_clusters() + target_clusters = target_zha_device.async_get_std_clusters() clusters_to_bind = [] for endpoint_id in source_clusters: @@ -193,8 +193,8 @@ async def get_matched_clusters(source_zha_device, target_zha_device): @callback def async_is_bindable_target(source_zha_device, target_zha_device): """Determine if target is bindable to source.""" - source_clusters = source_zha_device.async_get_zha_clusters() - target_clusters = target_zha_device.async_get_zha_clusters() + source_clusters = source_zha_device.async_get_std_clusters() + target_clusters = target_zha_device.async_get_std_clusters() bindables = set(BINDABLE_CLUSTERS) for endpoint_id in source_clusters: diff --git a/homeassistant/components/zha/core/patches.py b/homeassistant/components/zha/core/patches.py index 8f708a568d1..63c01ed04fd 100644 --- a/homeassistant/components/zha/core/patches.py +++ b/homeassistant/components/zha/core/patches.py @@ -5,23 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ -import types - - -def apply_cluster_listener_patch(): - """Apply patches to ZHA objects.""" - # patch zigpy listener to prevent flooding logs with warnings due to - # how zigpy implemented its listeners - from zigpy.appdb import ClusterPersistingListener - - def zha_send_event(self, cluster, command, args): - pass - - ClusterPersistingListener.zha_send_event = types.MethodType( - zha_send_event, - ClusterPersistingListener - ) - def apply_application_controller_patch(zha_gateway): """Apply patches to ZHA objects.""" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 3cbd31aa304..b585ce5f48a 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -5,6 +5,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.fan import DOMAIN as FAN +from homeassistant.components.light import DOMAIN as LIGHT +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH + from .const import ( HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, @@ -25,10 +31,17 @@ RADIO_TYPES = {} BINARY_SENSOR_TYPES = {} CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} -COMPONENT_CLUSTERS = {} EVENT_RELAY_CLUSTERS = [] NO_SENSOR_CLUSTERS = [] BINDABLE_CLUSTERS = [] +BINARY_SENSOR_CLUSTERS = set() +LIGHT_CLUSTERS = set() +SWITCH_CLUSTERS = set() +COMPONENT_CLUSTERS = { + BINARY_SENSOR: BINARY_SENSOR_CLUSTERS, + LIGHT: LIGHT_CLUSTERS, + SWITCH: SWITCH_CLUSTERS +} def establish_device_mappings(): @@ -38,7 +51,7 @@ def establish_device_mappings(): in a function. """ from zigpy import zcl - from zigpy.profiles import PROFILES, zha, zll + from zigpy.profiles import zha, zll if zha.PROFILE_ID not in DEVICE_CLASS: DEVICE_CLASS[zha.PROFILE_ID] = {} @@ -97,53 +110,55 @@ def establish_device_mappings(): BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ - zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', - zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', - zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', - zha.DeviceType.SMART_PLUG: 'switch', - zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', - zha.DeviceType.ON_OFF_LIGHT: 'light', - zha.DeviceType.DIMMABLE_LIGHT: 'light', - zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', - zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', - zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', - zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', + zha.DeviceType.ON_OFF_SWITCH: BINARY_SENSOR, + zha.DeviceType.LEVEL_CONTROL_SWITCH: BINARY_SENSOR, + zha.DeviceType.REMOTE_CONTROL: BINARY_SENSOR, + zha.DeviceType.SMART_PLUG: SWITCH, + zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, + zha.DeviceType.ON_OFF_LIGHT: LIGHT, + zha.DeviceType.DIMMABLE_LIGHT: LIGHT, + zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, + zha.DeviceType.ON_OFF_LIGHT_SWITCH: BINARY_SENSOR, + zha.DeviceType.DIMMER_SWITCH: BINARY_SENSOR, + zha.DeviceType.COLOR_DIMMER_SWITCH: BINARY_SENSOR, }) DEVICE_CLASS[zll.PROFILE_ID].update({ - zll.DeviceType.ON_OFF_LIGHT: 'light', - zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', - zll.DeviceType.DIMMABLE_LIGHT: 'light', - zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', - zll.DeviceType.COLOR_LIGHT: 'light', - zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', - zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', - zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', - zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.CONTROLLER: 'binary_sensor', - zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', + zll.DeviceType.ON_OFF_LIGHT: LIGHT, + zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH, + zll.DeviceType.DIMMABLE_LIGHT: LIGHT, + zll.DeviceType.DIMMABLE_PLUGIN_UNIT: LIGHT, + zll.DeviceType.COLOR_LIGHT: LIGHT, + zll.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT, + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, + zll.DeviceType.COLOR_CONTROLLER: BINARY_SENSOR, + zll.DeviceType.COLOR_SCENE_CONTROLLER: BINARY_SENSOR, + zll.DeviceType.CONTROLLER: BINARY_SENSOR, + zll.DeviceType.SCENE_CONTROLLER: BINARY_SENSOR, + zll.DeviceType.ON_OFF_SENSOR: BINARY_SENSOR, }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'switch', - zcl.clusters.measurement.RelativeHumidity: 'sensor', + zcl.clusters.general.OnOff: SWITCH, + zcl.clusters.measurement.RelativeHumidity: SENSOR, # this works for now but if we hit conflicts we can break it out to # a different dict that is keyed by manufacturer - SMARTTHINGS_HUMIDITY_CLUSTER: 'sensor', - zcl.clusters.measurement.TemperatureMeasurement: 'sensor', - zcl.clusters.measurement.PressureMeasurement: 'sensor', - zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', - zcl.clusters.smartenergy.Metering: 'sensor', - zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', - zcl.clusters.security.IasZone: 'binary_sensor', - zcl.clusters.measurement.OccupancySensing: 'binary_sensor', - zcl.clusters.hvac.Fan: 'fan', - SMARTTHINGS_ACCELERATION_CLUSTER: 'binary_sensor', + SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR, + zcl.clusters.measurement.TemperatureMeasurement: SENSOR, + zcl.clusters.measurement.PressureMeasurement: SENSOR, + zcl.clusters.measurement.IlluminanceMeasurement: SENSOR, + zcl.clusters.smartenergy.Metering: SENSOR, + zcl.clusters.homeautomation.ElectricalMeasurement: SENSOR, + zcl.clusters.security.IasZone: BINARY_SENSOR, + zcl.clusters.measurement.OccupancySensing: BINARY_SENSOR, + zcl.clusters.hvac.Fan: FAN, + SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, + zcl.clusters.general.MultistateInput.cluster_id: SENSOR, + zcl.clusters.general.AnalogInput.cluster_id: SENSOR }) SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'binary_sensor', + zcl.clusters.general.OnOff: BINARY_SENSOR, }) SENSOR_TYPES.update({ @@ -269,12 +284,15 @@ def establish_device_mappings(): }], }) - # A map of hass components to all Zigbee clusters it could use - for profile_id, classes in DEVICE_CLASS.items(): - profile = PROFILES[profile_id] - for device_type, component in classes.items(): - if component not in COMPONENT_CLUSTERS: - COMPONENT_CLUSTERS[component] = (set(), set()) - clusters = profile.CLUSTERS[device_type] - COMPONENT_CLUSTERS[component][0].update(clusters[0]) - COMPONENT_CLUSTERS[component][1].update(clusters[1]) + BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) + BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id) + BINARY_SENSOR_CLUSTERS.add(zcl.clusters.security.IasZone.cluster_id) + BINARY_SENSOR_CLUSTERS.add( + zcl.clusters.measurement.OccupancySensing.cluster_id) + BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER) + + LIGHT_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) + LIGHT_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id) + LIGHT_CLUSTERS.add(zcl.clusters.lighting.Color.cluster_id) + + SWITCH_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 3937e597b78..94fe598b6ec 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -142,8 +142,8 @@ class ZhaDeviceEntity(ZhaEntity): """Get the latest battery reading from channels cache.""" battery = await self._battery_channel.get_attribute_value( 'battery_percentage_remaining') - if battery is not None: - # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ + # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ + if battery is not None and battery != -1: battery = battery / 2 battery = int(round(battery)) self._device_state_attributes['battery_level'] = battery diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index ec840d5edb3..8395c2317e8 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -24,6 +24,7 @@ CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 SCAN_INTERVAL = timedelta(minutes=60) +PARALLEL_UPDATES = 5 async def async_setup_platform(hass, config, async_add_entities, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c8bc0479f30..9f1f69a11ec 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -3,11 +3,11 @@ "name": "Zigbee Home Automation", "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ - "bellows-homeassistant==0.7.2", - "zha-quirks==0.0.8", + "bellows-homeassistant==0.7.3", + "zha-quirks==0.0.13", "zigpy-deconz==0.1.4", - "zigpy-homeassistant==0.3.2", - "zigpy-xbee-homeassistant==0.2.0" + "zigpy-homeassistant==0.3.3", + "zigpy-xbee-homeassistant==0.2.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b6ac70fa187..b2d246c3095 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -14,6 +14,7 @@ from .core.const import ( SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) from .entity import ZhaEntity +PARALLEL_UPDATES = 5 _LOGGER = logging.getLogger(__name__) @@ -23,6 +24,13 @@ def pass_through_formatter(value): return value +def illuminance_formatter(value): + """Convert Illimination data.""" + if value is None: + return None + return round(pow(10, ((value - 1) / 10000)), 1) + + def temperature_formatter(value): """Convert temperature data.""" if value is None: @@ -57,6 +65,7 @@ FORMATTER_FUNC_REGISTRY = { TEMPERATURE: temperature_formatter, PRESSURE: pressure_formatter, ELECTRICAL_MEASUREMENT: active_power_formatter, + ILLUMINANCE: illuminance_formatter, GENERIC: pass_through_formatter, } diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json index 39947080d18..ba7885f2e25 100644 --- a/homeassistant/components/zwave/.translations/es.json +++ b/homeassistant/components/zwave/.translations/es.json @@ -13,7 +13,7 @@ "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", "usb_path": "Ruta USB" }, - "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", + "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", "title": "Configurar Z-Wave" } }, diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py index f33933a2772..e7e15d2303c 100755 --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -35,6 +35,8 @@ DEVICE_MAPPINGS = { (0x0090, 0x440): WORKAROUND_DEVICE_STATE, (0x0090, 0x446): WORKAROUND_DEVICE_STATE, (0x0090, 0x238): WORKAROUND_DEVICE_STATE, + # Kwikset 888ZW500-15S Smartcode 888 + (0x0090, 0x541): WORKAROUND_DEVICE_STATE, # Yale Locks # Yale YRD210, YRD220, YRL220 (0x0129, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, diff --git a/homeassistant/config.py b/homeassistant/config.py index 44008214535..1be3ba082e8 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -205,7 +205,8 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore -def ensure_config_exists(config_dir: str, detect_location: bool = True)\ +async def async_ensure_config_exists(hass: HomeAssistant, config_dir: str, + detect_location: bool = True)\ -> Optional[str]: """Ensure a configuration file exists in given configuration directory. @@ -217,18 +218,51 @@ def ensure_config_exists(config_dir: str, detect_location: bool = True)\ if config_path is None: print("Unable to find configuration. Creating default one in", config_dir) - config_path = create_default_config(config_dir, detect_location) + config_path = await async_create_default_config( + hass, config_dir, detect_location) return config_path -def create_default_config(config_dir: str, detect_location: bool = True)\ - -> Optional[str]: +async def async_create_default_config( + hass: HomeAssistant, config_dir: str, detect_location: bool = True + ) -> Optional[str]: """Create a default configuration file in given configuration directory. Return path to new config file if success, None if failed. This method needs to run in an executor. """ + info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} + + if detect_location: + session = hass.helpers.aiohttp_client.async_get_clientsession() + location_info = await loc_util.async_detect_location_info(session) + else: + location_info = None + + if location_info: + if location_info.use_metric: + info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + else: + info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + + for attr, default, prop, _ in DEFAULT_CORE_CONFIG: + if prop is None: + continue + info[attr] = getattr(location_info, prop) or default + + if location_info.latitude and location_info.longitude: + info[CONF_ELEVATION] = await loc_util.async_get_elevation( + session, location_info.latitude, location_info.longitude) + + return await hass.async_add_executor_job( + _write_default_config, config_dir, info + ) + + +def _write_default_config(config_dir: str, info: Dict)\ + -> Optional[str]: + """Write the default config.""" from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( @@ -246,25 +280,6 @@ def create_default_config(config_dir: str, detect_location: bool = True)\ script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) - info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} - - location_info = detect_location and loc_util.detect_location_info() - - if location_info: - if location_info.use_metric: - info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC - else: - info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL - - for attr, default, prop, _ in DEFAULT_CORE_CONFIG: - if prop is None: - continue - info[attr] = getattr(location_info, prop) or default - - if location_info.latitude and location_info.longitude: - info[CONF_ELEVATION] = loc_util.elevation( - location_info.latitude, location_info.longitude) - # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: @@ -576,8 +591,9 @@ async def async_process_ha_core_config( # If we miss some of the needed values, auto detect them if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): - info = await hass.async_add_executor_job( - loc_util.detect_location_info) + info = await loc_util.async_detect_location_info( + hass.helpers.aiohttp_client.async_get_clientsession() + ) if info is None: _LOGGER.error("Could not detect location information") @@ -602,8 +618,9 @@ async def async_process_ha_core_config( if hac.elevation is None and hac.latitude is not None and \ hac.longitude is not None: - elevation = await hass.async_add_executor_job( - loc_util.elevation, hac.latitude, hac.longitude) + elevation = await loc_util.async_get_elevation( + hass.helpers.aiohttp_client.async_get_clientsession(), + hac.latitude, hac.longitude) hac.elevation = elevation discovered.append(('elevation', elevation)) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 393a046b5a2..a2b34a00efd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -142,6 +142,7 @@ SOURCE_IMPORT = 'import' HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ + 'ambiclimate', 'ambient_station', 'axis', 'cast', diff --git a/homeassistant/const.py b/homeassistant/const.py index 8c012fcac8e..e32ae1015d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 92 -PATCH_VERSION = '2' +MINOR_VERSION = 93 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) diff --git a/homeassistant/core.py b/homeassistant/core.py index df315ad63c0..c127e100f11 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -828,8 +828,8 @@ class StateMachine: """ return self._states.get(entity_id.lower()) - def is_state(self, entity_id: str, state: State) -> bool: - """Test if entity exists and is specified state. + def is_state(self, entity_id: str, state: str) -> bool: + """Test if entity exists and is in specified state. Async friendly. """ @@ -907,7 +907,7 @@ class StateMachine: else: same_state = (old_state.state == new_state and not force_update) - same_attr = old_state.attributes == attributes + same_attr = old_state.attributes == MappingProxyType(attributes) last_changed = old_state.last_changed if same_state else None if same_state and same_attr: @@ -936,6 +936,9 @@ class Service: """Initialize a service.""" self.func = func self.schema = schema + # Properly detect wrapped functions + while isinstance(func, functools.partial): + func = func.func self.is_callback = is_callback(func) self.is_coroutinefunction = asyncio.iscoroutinefunction(func) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index adf5410516d..4476d526987 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -16,7 +16,7 @@ from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) DATA_REGISTRY = 'area_registry' - +EVENT_AREA_REGISTRY_UPDATED = 'area_registry_updated' STORAGE_KEY = 'core.area_registry' STORAGE_VERSION = 1 SAVE_DELAY = 10 @@ -58,7 +58,14 @@ class AreaRegistry: area = AreaEntry() self.areas[area.id] = area - return self.async_update(area.id, name=name) + created = self._async_update(area.id, name=name) + + self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, { + 'action': 'create', + 'area_id': created.id, + }) + + return created async def async_delete(self, area_id: str) -> None: """Delete area.""" @@ -68,10 +75,25 @@ class AreaRegistry: del self.areas[area_id] + self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, { + 'action': 'remove', + 'area_id': area_id, + }) + self.async_schedule_save() @callback def async_update(self, area_id: str, name: str) -> AreaEntry: + """Update name of area.""" + updated = self._async_update(area_id, name) + self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, { + 'action': 'update', + 'area_id': area_id, + }) + return updated + + @callback + def _async_update(self, area_id: str, name: str) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index a954d01856e..9282770de1a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,5 +1,6 @@ """Helpers for config validation using voluptuous.""" import inspect +import json import logging import os import re @@ -15,14 +16,13 @@ from pkg_resources import parse_version import homeassistant.util.dt as dt_util from homeassistant.const import ( - CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, - CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, - SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, - ENTITY_MATCH_ALL, CONF_ENTITY_NAMESPACE, __version__) + CONF_ABOVE, CONF_ALIAS, CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID, + CONF_ENTITY_NAMESPACE, CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, + CONF_TIMEOUT, ENTITY_MATCH_ALL, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + TEMP_CELSIUS, TEMP_FAHRENHEIT, WEEKDAYS, __version__) from homeassistant.core import valid_entity_id, split_entity_id from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template as template_helper from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import slugify as util_slugify @@ -444,6 +444,8 @@ unit_system = vol.All(vol.Lower, vol.Any(CONF_UNIT_SYSTEM_METRIC, def template(value): """Validate a jinja2 template.""" + from homeassistant.helpers import template as template_helper + if value is None: raise vol.Invalid('template value is None') if isinstance(value, (list, dict, template_helper.Template)): @@ -677,26 +679,53 @@ class HASchema(vol.Schema): self.extra = vol.PREVENT_EXTRA # This is a legacy config, print warning - extra_key_errs = [err for err in orig_err.errors + extra_key_errs = [err.path[-1] for err in orig_err.errors if err.error_message == 'extra keys not allowed'] - if extra_key_errs: - msg = "Your configuration contains extra keys " \ - "that the platform does not support.\n" \ - "Please remove " - submsg = ', '.join('[{}]'.format(err.path[-1]) for err in - extra_key_errs) - submsg += '. ' - if hasattr(data, '__config_file__'): - submsg += " (See {}, line {}). ".format( - data.__config_file__, data.__line__) - msg += submsg - logging.getLogger(__name__).warning(msg) - INVALID_EXTRA_KEYS_FOUND.append(submsg) - else: + + if not extra_key_errs: # This should not happen (all errors should be extra key # errors). Let's raise the original error anyway. raise orig_err + WHITELIST = [ + re.compile(CONF_NAME), + re.compile(CONF_PLATFORM), + re.compile('.*_topic'), + ] + + msg = "Your configuration contains extra keys " \ + "that the platform does not support.\n" \ + "Please remove " + submsg = ', '.join('[{}]'.format(err) for err in + extra_key_errs) + submsg += '. ' + + # Add file+line information, if available + if hasattr(data, '__config_file__'): + submsg += " (See {}, line {}). ".format( + data.__config_file__, data.__line__) + + # Add configuration source information, if available + if hasattr(data, '__configuration_source__'): + submsg += "\nConfiguration source: {}. ".format( + data.__configuration_source__) + redacted_data = {} + + # Print configuration causing the error, but filter any potentially + # sensitive data + for k, v in data.items(): + if (any(regex.match(k) for regex in WHITELIST) or + k in extra_key_errs): + redacted_data[k] = v + else: + redacted_data[k] = '' + submsg += "\nOffending data: {}".format( + json.dumps(redacted_data)) + + msg += submsg + logging.getLogger(__name__).warning(msg) + INVALID_EXTRA_KEYS_FOUND.append(submsg) + # Return legacy validated config return validated diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 25c9933fd11..5c066967437 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) _UNDEF = object() DATA_REGISTRY = 'device_registry' - +EVENT_DEVICE_REGISTRY_UPDATED = 'device_registry_updated' STORAGE_KEY = 'core.device_registry' STORAGE_VERSION = 1 SAVE_DELAY = 10 @@ -42,6 +42,8 @@ class DeviceEntry: area_id = attr.ib(type=str, default=None) name_by_user = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + # This value is not stored, just used to keep track of events to fire. + is_new = attr.ib(type=bool, default=False) def format_mac(mac): @@ -111,7 +113,7 @@ class DeviceRegistry: device = self.async_get_device(identifiers, connections) if device is None: - device = DeviceEntry() + device = DeviceEntry(is_new=True) self.devices[device.id] = device if via_hub is not None: @@ -134,16 +136,19 @@ class DeviceRegistry: @callback def async_update_device( - self, device_id, *, area_id=_UNDEF, name_by_user=_UNDEF): + self, device_id, *, area_id=_UNDEF, name_by_user=_UNDEF, + new_identifiers=_UNDEF): """Update properties of a device.""" return self._async_update_device( - device_id, area_id=area_id, name_by_user=name_by_user) + device_id, area_id=area_id, name_by_user=name_by_user, + new_identifiers=new_identifiers) @callback def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, remove_config_entry_id=_UNDEF, merge_connections=_UNDEF, merge_identifiers=_UNDEF, + new_identifiers=_UNDEF, manufacturer=_UNDEF, model=_UNDEF, name=_UNDEF, @@ -178,6 +183,9 @@ class DeviceRegistry: if value is not _UNDEF and not value.issubset(old_value): changes[attr_name] = old_value | value + if new_identifiers is not _UNDEF: + changes['identifiers'] = new_identifiers + for attr_name, value in ( ('manufacturer', manufacturer), ('model', model), @@ -195,11 +203,20 @@ class DeviceRegistry: name_by_user != old.name_by_user): changes['name_by_user'] = name_by_user + if old.is_new: + changes['is_new'] = False + if not changes: return old new = self.devices[device_id] = attr.evolve(old, **changes) self.async_schedule_save() + + self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, { + 'action': 'create' if 'is_new' in changes else 'update', + 'device_id': new.id, + }) + return new async def async_load(self): diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index be50d11d17d..0a0c441b9cf 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -25,6 +25,7 @@ from .typing import HomeAssistantType PATH_REGISTRY = 'entity_registry.yaml' DATA_REGISTRY = 'entity_registry' +EVENT_ENTITY_REGISTRY_UPDATED = 'entity_registry_updated' SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) _UNDEF = object() @@ -150,28 +151,39 @@ class EntityRegistry: _LOGGER.info('Registered new %s.%s entity: %s', domain, platform, entity_id) self.async_schedule_save() + + self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'create', + 'entity_id': entity_id + }) + return entity @callback def async_remove(self, entity_id): """Remove an entity from registry.""" self.entities.pop(entity_id) + self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'remove', + 'entity_id': entity_id + }) self.async_schedule_save() @callback def async_update_entity(self, entity_id, *, name=_UNDEF, - new_entity_id=_UNDEF): + new_entity_id=_UNDEF, new_unique_id=_UNDEF): """Update properties of an entity.""" return self._async_update_entity( entity_id, name=name, - new_entity_id=new_entity_id + new_entity_id=new_entity_id, + new_unique_id=new_unique_id ) @callback def _async_update_entity(self, entity_id, *, name=_UNDEF, config_entry_id=_UNDEF, new_entity_id=_UNDEF, - device_id=_UNDEF): + device_id=_UNDEF, new_unique_id=_UNDEF): """Private facing update properties method.""" old = self.entities[entity_id] @@ -201,6 +213,17 @@ class EntityRegistry: self.entities.pop(entity_id) entity_id = changes['entity_id'] = new_entity_id + if new_unique_id is not _UNDEF: + conflict = next((entity for entity in self.entities.values() + if entity.unique_id == new_unique_id + and entity.domain == old.domain + and entity.platform == old.platform), None) + if conflict: + raise ValueError( + "Unique id '{}' is already in use by '{}'".format( + new_unique_id, conflict.entity_id)) + changes['unique_id'] = new_unique_id + if not changes: return old @@ -222,6 +245,11 @@ class EntityRegistry: self.async_schedule_save() + self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'update', + 'entity_id': entity_id + }) + return new async def async_load(self): diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 24275c87061..203e460aaa5 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1,21 +1,21 @@ """Template helper methods for rendering strings with Home Assistant data.""" -from datetime import datetime +import base64 import json import logging import math import random -import base64 import re +from datetime import datetime import jinja2 from jinja2 import contextfilter from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, MATCH_ALL, - STATE_UNKNOWN) -from homeassistant.core import State, valid_entity_id +from homeassistant.const import (ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL, + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) +from homeassistant.core import ( + State, callback, valid_entity_id, split_entity_id) from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper from homeassistant.helpers.typing import TemplateVarsType @@ -29,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" +_RENDER_INFO = 'template.render_info' + _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)" @@ -89,6 +91,54 @@ def extract_entities(template, variables=None): return MATCH_ALL +def _true(arg) -> bool: + return True + + +class RenderInfo: + """Holds information about a template render.""" + + def __init__(self, template): + """Initialise.""" + self.template = template + # Will be set sensibly once frozen. + self.filter_lifecycle = _true + self._result = None + self._exception = None + self._all_states = False + self._domains = [] + self._entities = [] + + def filter(self, entity_id: str) -> bool: + """Template should re-render if the state changes.""" + return entity_id in self._entities + + def _filter_lifecycle(self, entity_id: str) -> bool: + """Template should re-render if the state changes.""" + return ( + split_entity_id(entity_id)[0] in self._domains + or entity_id in self._entities) + + @property + def result(self) -> str: + """Results of the template computation.""" + if self._exception is not None: + raise self._exception # pylint: disable=raising-bad-type + return self._result + + def _freeze(self) -> None: + self._entities = frozenset(self._entities) + if self._all_states: + # Leave lifecycle_filter as True + del self._domains + elif not self._domains: + del self._domains + self.filter_lifecycle = self.filter + else: + self._domains = frozenset(self._domains) + self.filter_lifecycle = self._filter_lifecycle + + class Template: """Class to hold a template and manage caching and rendering.""" @@ -124,6 +174,7 @@ class Template: return run_callback_threadsafe( self.hass.loop, self.async_render, kwargs).result() + @callback def async_render(self, variables: TemplateVarsType = None, **kwargs) -> str: """Render given template. @@ -141,6 +192,23 @@ class Template: except jinja2.TemplateError as err: raise TemplateError(err) + @callback + def async_render_to_info( + self, variables: TemplateVarsType = None, + **kwargs) -> RenderInfo: + """Render the template and collect an entity filter.""" + assert self.hass and _RENDER_INFO not in self.hass.data + render_info = self.hass.data[_RENDER_INFO] = RenderInfo(self) + # pylint: disable=protected-access + try: + render_info._result = self.async_render(variables, **kwargs) + except TemplateError as ex: + render_info._exception = ex + finally: + del self.hass.data[_RENDER_INFO] + render_info._freeze() + return render_info + def render_with_possible_json_value(self, value, error_value=_SENTINEL): """Render template with value exposed. @@ -150,6 +218,7 @@ class Template: self.hass.loop, self.async_render_with_possible_json_value, value, error_value).result() + @callback def async_render_with_possible_json_value(self, value, error_value=_SENTINEL, variables=None): @@ -190,7 +259,7 @@ class Template: global_vars = ENV.make_globals({ 'closest': template_methods.closest, 'distance': template_methods.distance, - 'is_state': self.hass.states.is_state, + 'is_state': template_methods.is_state, 'is_state_attr': template_methods.is_state_attr, 'state_attr': template_methods.state_attr, 'states': AllStates(self.hass), @@ -207,6 +276,14 @@ class Template: self.template == other.template and self.hass == other.hass) + def __hash__(self): + """Hash code for template.""" + return hash(self.template) + + def __repr__(self): + """Representation of Template.""" + return 'Template(\"' + self.template + '\")' + class AllStates: """Class to expose all HA states as attributes.""" @@ -217,24 +294,42 @@ class AllStates: def __getattr__(self, name): """Return the domain state.""" + if '.' in name: + if not valid_entity_id(name): + raise TemplateError("Invalid entity ID '{}'".format(name)) + return _get_state(self._hass, name) + if not valid_entity_id(name + '.entity'): + raise TemplateError("Invalid domain name '{}'".format(name)) return DomainStates(self._hass, name) + def _collect_all(self): + render_info = self._hass.data.get(_RENDER_INFO) + if render_info is not None: + # pylint: disable=protected-access + render_info._all_states = True + def __iter__(self): """Return all states.""" + self._collect_all() return iter( - _wrap_state(state) for state in + _wrap_state(self._hass, state) for state in sorted(self._hass.states.async_all(), key=lambda state: state.entity_id)) def __len__(self): """Return number of states.""" + self._collect_all() return len(self._hass.states.async_entity_ids()) def __call__(self, entity_id): """Return the states.""" - state = self._hass.states.get(entity_id) + state = _get_state(self._hass, entity_id) return STATE_UNKNOWN if state is None else state.state + def __repr__(self): + """Representation of All States.""" + return '